mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-20 03:28:05 +00:00
Add Yang-Zhang Volatility (YZV) Indicator Implementation
- Introduced YZV class for calculating Yang-Zhang Volatility, a comprehensive volatility measure that incorporates overnight, open-to-close, and high-low components. - Implemented calculation methods, including batch processing for TBarSeries and spans. - Added documentation for YZV, detailing its mathematical foundation, performance profile, and trading applications. - Updated volume index documentation to reflect changes in file paths. - Refactored VWMA calculation method to use a more generic source parameter instead of price.
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class VrIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void VrIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new VrIndicator();
|
||||
|
||||
Assert.Equal(14, indicator.Period);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("VR - Volatility Ratio", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VrIndicator_ShortName_IncludesParameters()
|
||||
{
|
||||
var indicator = new VrIndicator { Period = 20 };
|
||||
Assert.Contains("VR", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("20", indicator.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VrIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new VrIndicator();
|
||||
|
||||
Assert.Equal(0, VrIndicator.MinHistoryDepths);
|
||||
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VrIndicator_Initialize_CreatesInternalVr()
|
||||
{
|
||||
var indicator = new VrIndicator();
|
||||
|
||||
// Initialize should not throw
|
||||
indicator.Initialize();
|
||||
|
||||
// After init, line series should exist
|
||||
Assert.Single(indicator.LinesSeries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VrIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new VrIndicator { Period = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
// Add historical data with varying volatility
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
// Create price movement that generates volatility
|
||||
double basePrice = 100 + Math.Sin(i * 0.3) * (5 + i * 0.1);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 2, basePrice - 2, basePrice, 1000);
|
||||
|
||||
// Process update for each bar to simulate history loading
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
// Line series should have a value
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(val));
|
||||
Assert.True(val >= 0, "VR should be non-negative");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VrIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new VrIndicator { Period = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
double basePrice = 100 + i;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 2, basePrice - 2, basePrice + 1, 1000);
|
||||
}
|
||||
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
// Add new bar
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(30), 130, 135, 125, 132, 1500);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
Assert.Equal(2, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VrIndicator_DifferentPeriods_Work()
|
||||
{
|
||||
var periods = new[] { 5, 10, 14, 20 };
|
||||
|
||||
foreach (int period in periods)
|
||||
{
|
||||
var indicator = new VrIndicator { Period = period };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 60; i++)
|
||||
{
|
||||
// Create price movement with varying amplitude
|
||||
double basePrice = 100 + Math.Sin(i * 0.2) * 5;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 2, basePrice - 2, basePrice, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(val), $"Period {period} should produce finite value");
|
||||
Assert.True(val >= 0, $"Period {period} should produce non-negative value");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VrIndicator_Period_CanBeChanged()
|
||||
{
|
||||
var indicator = new VrIndicator();
|
||||
Assert.Equal(14, indicator.Period);
|
||||
|
||||
indicator.Period = 20;
|
||||
Assert.Equal(20, indicator.Period);
|
||||
|
||||
indicator.Period = 10;
|
||||
Assert.Equal(10, indicator.Period);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VrIndicator_ShowColdValues_CanBeToggled()
|
||||
{
|
||||
var indicator = new VrIndicator();
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
|
||||
indicator.ShowColdValues = false;
|
||||
Assert.False(indicator.ShowColdValues);
|
||||
|
||||
indicator.ShowColdValues = true;
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VrIndicator_SourceCodeLink_IsValid()
|
||||
{
|
||||
var indicator = new VrIndicator();
|
||||
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
Assert.Contains("Vr.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VrIndicator_ConstantPrice_ProducesNearOne()
|
||||
{
|
||||
var indicator = new VrIndicator { Period = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Constant price with small range - TR ≈ ATR so VR ≈ 1
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 101, 99, 100, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(val));
|
||||
// VR should be around 1 when volatility is constant
|
||||
Assert.True(val >= 0.5 && val <= 2.0, $"Constant volatility should produce VR near 1, got {val}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VrIndicator_HighVolatility_ProducesPositiveValue()
|
||||
{
|
||||
var indicator = new VrIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// High volatility with large price swings
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
double price = 100 + (i % 2 == 0 ? 10 : -10); // Large oscillations
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 5, price - 5, price, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(val));
|
||||
Assert.True(val > 0, "High volatility should produce positive VR value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VrIndicator_UsesHLC_ForCalculation()
|
||||
{
|
||||
// VR uses HLC (True Range / ATR)
|
||||
var indicator = new VrIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Price with varying HLC
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
double close = 100 + Math.Sin(i * 0.3) * 3;
|
||||
double high = close + 2 + Math.Abs(Math.Sin(i * 0.5));
|
||||
double low = close - 2 - Math.Abs(Math.Cos(i * 0.5));
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), close, high, low, close, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(val));
|
||||
Assert.True(val >= 0, "VR should be non-negative");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VrIndicator_BreakoutDetection_HighRatio()
|
||||
{
|
||||
var indicator = new VrIndicator { Period = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Calm period - small ranges
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 101, 99, 100, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double calmVr = indicator.LinesSeries[0].GetValue(0);
|
||||
|
||||
// Breakout - large range
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(20), 100, 115, 85, 110, 5000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
double breakoutVr = indicator.LinesSeries[0].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(calmVr));
|
||||
Assert.True(double.IsFinite(breakoutVr));
|
||||
Assert.True(breakoutVr > calmVr, "Breakout should produce higher VR than calm period");
|
||||
Assert.True(breakoutVr > 1.5, "Breakout VR should be significantly above 1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VrIndicator_LargerPeriod_SmootherATR()
|
||||
{
|
||||
var indicator1 = new VrIndicator { Period = 5 };
|
||||
var indicator2 = new VrIndicator { Period = 20 };
|
||||
indicator1.Initialize();
|
||||
indicator2.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var results1 = new List<double>();
|
||||
var results2 = new List<double>();
|
||||
|
||||
for (int i = 0; i < 60; i++)
|
||||
{
|
||||
double price = 100 + Math.Sin(i * 0.3) * 5;
|
||||
indicator1.HistoricalData.AddBar(now.AddMinutes(i), price, price + 2, price - 2, price, 1000);
|
||||
indicator2.HistoricalData.AddBar(now.AddMinutes(i), price, price + 2, price - 2, price, 1000);
|
||||
indicator1.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
indicator2.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
if (i >= 25) // After both are fully warmed up
|
||||
{
|
||||
results1.Add(indicator1.LinesSeries[0].GetValue(0));
|
||||
results2.Add(indicator2.LinesSeries[0].GetValue(0));
|
||||
}
|
||||
}
|
||||
|
||||
// Both should produce valid values
|
||||
Assert.True(results1.All(double.IsFinite));
|
||||
Assert.True(results2.All(double.IsFinite));
|
||||
}
|
||||
|
||||
private static double CalculateChangeVariance(List<double> values)
|
||||
{
|
||||
if (values.Count < 2)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var changes = new List<double>();
|
||||
for (int i = 1; i < values.Count; i++)
|
||||
{
|
||||
changes.Add(values[i] - values[i - 1]);
|
||||
}
|
||||
|
||||
double mean = changes.Average();
|
||||
double variance = changes.Select(c => (c - mean) * (c - mean)).Average();
|
||||
return variance;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VrIndicator_GapUp_IncreasesRatio()
|
||||
{
|
||||
var indicator = new VrIndicator { Period = 10 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Normal trading
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 101, 99, 100, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double beforeGap = indicator.LinesSeries[0].GetValue(0);
|
||||
|
||||
// Large gap up - TR will be large due to gap from previous close
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(15), 110, 115, 108, 112, 2000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
double afterGap = indicator.LinesSeries[0].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(beforeGap));
|
||||
Assert.True(double.IsFinite(afterGap));
|
||||
Assert.True(afterGap > beforeGap, "Gap should increase VR");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VrIndicator_VolatilityExpansion_RespondsQuickly()
|
||||
{
|
||||
var indicator = new VrIndicator { Period = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Low volatility period
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 100.5, 99.5, 100, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double lowVolVr = indicator.LinesSeries[0].GetValue(0);
|
||||
|
||||
// Sudden volatility expansion
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(15), 100, 110, 90, 105, 5000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
double expansionVr = indicator.LinesSeries[0].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(lowVolVr));
|
||||
Assert.True(double.IsFinite(expansionVr));
|
||||
Assert.True(expansionVr > lowVolVr * 2, "VR should respond quickly to volatility expansion");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VrIndicator_TypicalValues_AroundOne()
|
||||
{
|
||||
var indicator = new VrIndicator { Period = 14 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var values = new List<double>();
|
||||
|
||||
// Normal market with consistent volatility
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double price = 100 + Math.Sin(i * 0.1) * 2;
|
||||
double range = 2 + Math.Sin(i * 0.2) * 0.5; // Consistent range
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + range, price - range, price, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
if (i >= 20)
|
||||
{
|
||||
values.Add(indicator.LinesSeries[0].GetValue(0));
|
||||
}
|
||||
}
|
||||
|
||||
double avgVr = values.Average();
|
||||
|
||||
// In steady state with consistent volatility, VR should hover around 1
|
||||
Assert.True(avgVr >= 0.5 && avgVr <= 2.0, $"Average VR should be around 1, got {avgVr}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class VrIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Period", sortIndex: 1, 1, 200, 1, 0)]
|
||||
public int Period { get; set; } = 14;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Vr _vr = null!;
|
||||
private readonly LineSeries _series;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"VR({Period})";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/volatility/vr/Vr.Quantower.cs";
|
||||
|
||||
public VrIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
Name = "VR - Volatility Ratio";
|
||||
Description = "Volatility Ratio measures current True Range relative to Average True Range, identifying potential breakout conditions when the ratio exceeds threshold values";
|
||||
|
||||
_series = new LineSeries(name: "VR", color: IndicatorExtensions.Volatility, width: 2, style: LineStyle.Solid);
|
||||
AddLineSeries(_series);
|
||||
}
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
_vr = new Vr(Period);
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
TBar bar = this.GetInputBar(args);
|
||||
TValue result = _vr.Update(bar, isNew: args.IsNewBar());
|
||||
_series.SetValue(result.Value, _vr.IsHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
// Volatility Ratio (VR) Unit Tests
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class VrTests
|
||||
{
|
||||
private readonly GBM _gbm;
|
||||
private const double Tolerance = 1e-10;
|
||||
private const int DefaultPeriod = 14;
|
||||
|
||||
public VrTests()
|
||||
{
|
||||
_gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
}
|
||||
|
||||
private TBarSeries GenerateBarData(int count)
|
||||
{
|
||||
_gbm.Reset(DateTime.UtcNow.Ticks);
|
||||
return _gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultParameters_SetsCorrectValues()
|
||||
{
|
||||
var vr = new Vr();
|
||||
Assert.Equal(DefaultPeriod, vr.Period);
|
||||
Assert.Equal($"Vr({DefaultPeriod})", vr.Name);
|
||||
Assert.Equal(DefaultPeriod, vr.WarmupPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CustomPeriod_SetsCorrectValues()
|
||||
{
|
||||
var vr = new Vr(period: 20);
|
||||
Assert.Equal(20, vr.Period);
|
||||
Assert.Equal("Vr(20)", vr.Name);
|
||||
Assert.Equal(20, vr.WarmupPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ZeroPeriod_ThrowsArgumentException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Vr(period: 0));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativePeriod_ThrowsArgumentException()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Vr(period: -5));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithTBarSeriesSource_PrimesIndicator()
|
||||
{
|
||||
var bars = GenerateBarData(50);
|
||||
var vr = new Vr(bars, period: 10);
|
||||
Assert.True(vr.IsHot);
|
||||
Assert.True(double.IsFinite(vr.Last.Value));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Basic Calculation Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_SingleBar_ReturnsNonNegativeValue()
|
||||
{
|
||||
var vr = new Vr();
|
||||
var bar = new TBar(DateTime.UtcNow, 100.0, 102.0, 98.0, 101.0, 1000);
|
||||
var result = vr.Update(bar);
|
||||
Assert.True(result.Value >= 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ConstantTR_ProducesVRNearOne()
|
||||
{
|
||||
var vr = new Vr(period: 5);
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
// Consistent range: VR should converge to 1.0
|
||||
vr.Update(new TBar(DateTime.UtcNow, 100.0, 102.0, 98.0, 101.0, 1000));
|
||||
}
|
||||
// With constant TR, VR should be near 1.0
|
||||
Assert.True(vr.Last.Value > 0.9 && vr.Last.Value < 1.1, $"Expected near 1.0, got {vr.Last.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ReturnsNonNegativeValue()
|
||||
{
|
||||
var vr = new Vr();
|
||||
var bars = GenerateBarData(100);
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
var result = vr.Update(bars[i]);
|
||||
Assert.True(result.Value >= 0, $"VR should be non-negative, got {result.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_HighVolatilityBar_ProducesVRAboveOne()
|
||||
{
|
||||
var vr = new Vr(period: 10);
|
||||
|
||||
// Build up ATR with normal bars
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
vr.Update(new TBar(DateTime.UtcNow, 100.0, 101.0, 99.0, 100.5, 1000));
|
||||
}
|
||||
|
||||
// High volatility bar: TR much larger than ATR
|
||||
var highVolBar = new TBar(DateTime.UtcNow, 100.0, 110.0, 90.0, 105.0, 1000);
|
||||
var result = vr.Update(highVolBar);
|
||||
|
||||
Assert.True(result.Value > 1.0, $"VR should be > 1.0 for high vol bar, got {result.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_LowVolatilityBar_ProducesVRBelowOne()
|
||||
{
|
||||
var vr = new Vr(period: 10);
|
||||
|
||||
// Build up ATR with normal bars
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
vr.Update(new TBar(DateTime.UtcNow, 100.0, 105.0, 95.0, 102.0, 1000));
|
||||
}
|
||||
|
||||
// Low volatility bar: TR much smaller than ATR
|
||||
var lowVolBar = new TBar(DateTime.UtcNow, 100.0, 100.5, 99.5, 100.2, 1000);
|
||||
var result = vr.Update(lowVolBar);
|
||||
|
||||
Assert.True(result.Value < 1.0, $"VR should be < 1.0 for low vol bar, got {result.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_GapIncludedInTR_ProducesCorrectVR()
|
||||
{
|
||||
var vr = new Vr(period: 10);
|
||||
|
||||
// Build up some history
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
vr.Update(new TBar(DateTime.UtcNow, 100.0, 101.0, 99.0, 100.0, 1000));
|
||||
}
|
||||
|
||||
// Gap up: High-PrevClose should be largest component
|
||||
var gapBar = new TBar(DateTime.UtcNow, 105.0, 106.0, 104.0, 105.5, 1000);
|
||||
var result = vr.Update(gapBar);
|
||||
|
||||
// TR = max(2, 6, 4) = 6 (High - PrevClose = 106 - 100 = 6)
|
||||
Assert.True(result.Value > 1.0, $"Gap bar should produce VR > 1.0, got {result.Value}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsHot and Warmup Tests
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BeforeWarmup_ReturnsFalse()
|
||||
{
|
||||
var vr = new Vr(period: 10);
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
vr.Update(new TBar(DateTime.UtcNow, 100.0 + i, 102.0 + i, 98.0 + i, 101.0 + i, 1000));
|
||||
}
|
||||
Assert.False(vr.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_AfterWarmup_ReturnsTrue()
|
||||
{
|
||||
var vr = new Vr(period: 10);
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
vr.Update(new TBar(DateTime.UtcNow, 100.0 + i, 102.0 + i, 98.0 + i, 101.0 + i, 1000));
|
||||
}
|
||||
Assert.True(vr.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmupPeriod_EqualsToPeriod()
|
||||
{
|
||||
var vr = new Vr(period: 15);
|
||||
Assert.Equal(15, vr.WarmupPeriod);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Bar Correction (isNew) Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewTrue_AdvancesState()
|
||||
{
|
||||
var vr = new Vr(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
vr.Update(new TBar(time.AddSeconds(i), 100 + i, 102 + i, 98 + i, 101 + i, 1000), isNew: true);
|
||||
}
|
||||
|
||||
double valueBeforeNew = vr.Last.Value;
|
||||
vr.Update(new TBar(time.AddSeconds(10), 150, 155, 145, 152, 1000), isNew: true);
|
||||
|
||||
Assert.NotEqual(valueBeforeNew, vr.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewFalse_UpdatesCurrentBar()
|
||||
{
|
||||
var vr = new Vr(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
vr.Update(new TBar(time.AddSeconds(i), 100 + i, 102 + i, 98 + i, 101 + i, 1000), isNew: true);
|
||||
}
|
||||
|
||||
double valueBeforeCorrection = vr.Last.Value;
|
||||
|
||||
// First correction
|
||||
vr.Update(new TBar(time.AddSeconds(15), 200, 210, 190, 205, 1000), isNew: false);
|
||||
double valueAfterCorrection1 = vr.Last.Value;
|
||||
|
||||
// Second correction to different value
|
||||
vr.Update(new TBar(time.AddSeconds(15), 50, 55, 45, 52, 1000), isNew: false);
|
||||
double valueAfterCorrection2 = vr.Last.Value;
|
||||
|
||||
Assert.NotEqual(valueBeforeCorrection, valueAfterCorrection1);
|
||||
Assert.NotEqual(valueAfterCorrection1, valueAfterCorrection2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_MultipleCorrections_RestoresPreviousState()
|
||||
{
|
||||
var vr = new Vr(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
vr.Update(new TBar(time.AddSeconds(i), 100 + i, 102 + i, 98 + i, 101 + i, 1000), isNew: true);
|
||||
}
|
||||
|
||||
// Add a new bar
|
||||
var newBar = new TBar(time.AddSeconds(15), 115, 117, 113, 116, 1000);
|
||||
vr.Update(newBar, isNew: true);
|
||||
double baseValue = vr.Last.Value;
|
||||
|
||||
// Multiple corrections should all restore to same base state
|
||||
vr.Update(new TBar(time.AddSeconds(15), 200, 210, 190, 205, 1000), isNew: false);
|
||||
vr.Update(newBar, isNew: false);
|
||||
double restoredValue = vr.Last.Value;
|
||||
|
||||
Assert.Equal(baseValue, restoredValue, 10);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reset Tests
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsAllState()
|
||||
{
|
||||
var vr = new Vr(period: 5);
|
||||
var bars = GenerateBarData(20);
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
vr.Update(bars[i]);
|
||||
}
|
||||
|
||||
Assert.True(vr.IsHot);
|
||||
|
||||
vr.Reset();
|
||||
|
||||
Assert.False(vr.IsHot);
|
||||
Assert.Equal(default, vr.Last);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_AllowsReuse()
|
||||
{
|
||||
var vr = new Vr(period: 5);
|
||||
var bars = GenerateBarData(20);
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
vr.Update(bars[i]);
|
||||
}
|
||||
double firstRunValue = vr.Last.Value;
|
||||
|
||||
vr.Reset();
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
vr.Update(bars[i]);
|
||||
}
|
||||
double secondRunValue = vr.Last.Value;
|
||||
|
||||
Assert.Equal(firstRunValue, secondRunValue, 10);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NaN and Infinity Handling Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_NaNInput_UsesLastValidValue()
|
||||
{
|
||||
var vr = new Vr(period: 5);
|
||||
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
vr.Update(new TBar(DateTime.UtcNow, 100 + i, 102 + i, 98 + i, 101 + i, 1000));
|
||||
}
|
||||
|
||||
// Update with NaN
|
||||
vr.Update(new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 1000));
|
||||
Assert.True(double.IsFinite(vr.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_InfinityInput_UsesLastValidValue()
|
||||
{
|
||||
var vr = new Vr(period: 5);
|
||||
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
vr.Update(new TBar(DateTime.UtcNow, 100 + i, 102 + i, 98 + i, 101 + i, 1000));
|
||||
}
|
||||
|
||||
vr.Update(new TBar(DateTime.UtcNow, double.PositiveInfinity, double.PositiveInfinity, 98, 101, 1000));
|
||||
Assert.True(double.IsFinite(vr.Last.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_MultipleNaNs_StaysFinite()
|
||||
{
|
||||
var vr = new Vr(period: 5);
|
||||
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
vr.Update(new TBar(DateTime.UtcNow, 100 + i, 102 + i, 98 + i, 101 + i, 1000));
|
||||
}
|
||||
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
vr.Update(new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 1000));
|
||||
}
|
||||
|
||||
Assert.True(double.IsFinite(vr.Last.Value));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TBarSeries and Batch Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_TBarSeries_ReturnsCorrectLength()
|
||||
{
|
||||
var vr = new Vr();
|
||||
var bars = GenerateBarData(100);
|
||||
|
||||
var result = vr.Update(bars);
|
||||
Assert.Equal(bars.Count, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Static_ProducesValidResults()
|
||||
{
|
||||
var bars = GenerateBarData(100);
|
||||
|
||||
var result = Vr.Calculate(bars, period: 10);
|
||||
|
||||
Assert.Equal(bars.Count, result.Count);
|
||||
for (int i = 0; i < result.Count; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(result.Values[i]));
|
||||
Assert.True(result.Values[i] >= 0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_ProducesConsistentResults()
|
||||
{
|
||||
var bars = GenerateBarData(100);
|
||||
|
||||
double[] output = new double[100];
|
||||
Vr.Batch(bars, output, period: 10);
|
||||
|
||||
for (int i = 0; i < output.Length; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(output[i]));
|
||||
Assert.True(output[i] >= 0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_ZeroPeriod_ThrowsArgumentException()
|
||||
{
|
||||
var bars = GenerateBarData(10);
|
||||
double[] output = new double[10];
|
||||
var ex = Assert.Throws<ArgumentException>(() => Vr.Batch(bars, output, period: 0));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_OutputTooSmall_ThrowsArgumentException()
|
||||
{
|
||||
var bars = GenerateBarData(10);
|
||||
double[] output = new double[5];
|
||||
var ex = Assert.Throws<ArgumentException>(() => Vr.Batch(bars, output));
|
||||
Assert.Equal("output", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_EmptySource_DoesNotThrow()
|
||||
{
|
||||
var bars = new TBarSeries();
|
||||
double[] output = [];
|
||||
Vr.Batch(bars, output);
|
||||
Assert.Empty(output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_HlcArrays_ProducesValidResults()
|
||||
{
|
||||
int len = 50;
|
||||
double[] high = new double[len];
|
||||
double[] low = new double[len];
|
||||
double[] close = new double[len];
|
||||
double[] output = new double[len];
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
high[i] = 102 + i;
|
||||
low[i] = 98 + i;
|
||||
close[i] = 101 + i;
|
||||
}
|
||||
|
||||
Vr.Batch(high, low, close, output, period: 10);
|
||||
|
||||
for (int i = 0; i < output.Length; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(output[i]));
|
||||
Assert.True(output[i] >= 0);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mode Consistency Tests
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResults()
|
||||
{
|
||||
var bars = GenerateBarData(100);
|
||||
int period = 10;
|
||||
|
||||
// Mode 1: Streaming
|
||||
var streamingVr = new Vr(period);
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
streamingVr.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
// Mode 2: TBarSeries batch
|
||||
var batchResult = Vr.Calculate(bars, period);
|
||||
|
||||
// Mode 3: Span batch
|
||||
double[] spanOutput = new double[bars.Count];
|
||||
Vr.Batch(bars, spanOutput, period);
|
||||
|
||||
// Compare last 50 values (after warmup)
|
||||
int compareStart = bars.Count - 50;
|
||||
for (int i = compareStart; i < bars.Count; i++)
|
||||
{
|
||||
double batch = batchResult[i].Value;
|
||||
double span = spanOutput[i];
|
||||
|
||||
Assert.Equal(batch, span, Tolerance);
|
||||
}
|
||||
|
||||
// Final values should match
|
||||
Assert.Equal(streamingVr.Last.Value, batchResult[bars.Count - 1].Value, 1e-8);
|
||||
Assert.Equal(streamingVr.Last.Value, spanOutput[bars.Count - 1], 1e-8);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Event Tests
|
||||
|
||||
[Fact]
|
||||
public void Pub_FiresOnUpdate()
|
||||
{
|
||||
var vr = new Vr(period: 5);
|
||||
int eventCount = 0;
|
||||
|
||||
vr.Pub += (object? sender, in TValueEventArgs args) => eventCount++;
|
||||
|
||||
var time = DateTime.UtcNow;
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
vr.Update(new TBar(time.AddSeconds(i), 100 + i, 102 + i, 98 + i, 101 + i, 1000));
|
||||
}
|
||||
|
||||
Assert.Equal(5, eventCount);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TValue Input Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_TValue_CreatesSyntheticBar()
|
||||
{
|
||||
var vr1 = new Vr(period: 5);
|
||||
var vr2 = new Vr(period: 5);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
// TValue input creates bar with O=H=L=C
|
||||
vr1.Update(new TValue(time.AddSeconds(i), 100.0 + i));
|
||||
vr2.Update(new TBar(time.AddSeconds(i), 100.0 + i, 100.0 + i, 100.0 + i, 100.0 + i, 0));
|
||||
}
|
||||
|
||||
Assert.Equal(vr1.Last.Value, vr2.Last.Value, Tolerance);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Large Period Tests
|
||||
|
||||
[Fact]
|
||||
public void LargeDataset_NoStackOverflow()
|
||||
{
|
||||
var bars = GenerateBarData(10000);
|
||||
|
||||
double[] output = new double[10000];
|
||||
Vr.Batch(bars, output, period: 14);
|
||||
|
||||
for (int i = 0; i < output.Length; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(output[i]));
|
||||
Assert.True(output[i] >= 0);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Prime Tests
|
||||
|
||||
[Fact]
|
||||
public void Prime_SetsInitialState()
|
||||
{
|
||||
var vr = new Vr(period: 5);
|
||||
double[] warmupData = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109];
|
||||
|
||||
vr.Prime(warmupData);
|
||||
|
||||
Assert.True(vr.IsHot);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region VR Specific Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_TrueRangeCalculation_IncludesGaps()
|
||||
{
|
||||
var vr = new Vr(period: 5);
|
||||
|
||||
// First bar establishes previous close
|
||||
vr.Update(new TBar(DateTime.UtcNow, 100, 101, 99, 100, 1000));
|
||||
|
||||
// Gap up bar: High-PrevClose > H-L
|
||||
// PrevClose = 100, Current bar: O=105, H=107, L=104, C=106
|
||||
// TR = max(3, 7, 4) = 7 (High - PrevClose)
|
||||
var gapUpBar = new TBar(DateTime.UtcNow, 105, 107, 104, 106, 1000);
|
||||
vr.Update(gapUpBar);
|
||||
|
||||
// The TR should incorporate the gap
|
||||
Assert.True(vr.Last.Value > 0, "VR should be positive with gap");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_BiasCorrection_WorksDuringWarmup()
|
||||
{
|
||||
var vr = new Vr(period: 20);
|
||||
var bars = GenerateBarData(5);
|
||||
|
||||
// During warmup, bias correction should prevent extreme values
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
var result = vr.Update(bars[i]);
|
||||
Assert.True(double.IsFinite(result.Value), $"Value at index {i} should be finite");
|
||||
Assert.True(result.Value >= 0, $"Value at index {i} should be non-negative");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_VRMeanReverts_TowardsOne()
|
||||
{
|
||||
var vr = new Vr(period: 10);
|
||||
|
||||
// Build up history with varying volatility
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
double range = 2.0 + (i % 5) * 0.5; // Varying range
|
||||
vr.Update(new TBar(DateTime.UtcNow, 100, 100 + range, 100 - range, 100 + range / 2, 1000));
|
||||
}
|
||||
|
||||
// VR should oscillate around 1.0 over time
|
||||
// After many bars, the average should be close to 1.0
|
||||
Assert.True(vr.Last.Value > 0, "VR should be positive");
|
||||
Assert.True(double.IsFinite(vr.Last.Value), "VR should be finite");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
// Volatility Ratio (VR) Validation Tests
|
||||
// Validates against the PineScript reference implementation
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class VrValidationTests
|
||||
{
|
||||
private readonly GBM _gbm;
|
||||
private const double PineScriptTolerance = 1e-6;
|
||||
|
||||
public VrValidationTests()
|
||||
{
|
||||
_gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
}
|
||||
|
||||
private TBarSeries GenerateBarData(int count)
|
||||
{
|
||||
_gbm.Reset(DateTime.UtcNow.Ticks);
|
||||
return _gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
#region PineScript Algorithm Validation
|
||||
|
||||
[Fact]
|
||||
public void Vr_TrueRangeCalculation_MatchesPineScript()
|
||||
{
|
||||
// TR = max(high - low, abs(high - prevClose), abs(low - prevClose))
|
||||
double prevClose = 100.0;
|
||||
double high = 105.0;
|
||||
double low = 98.0;
|
||||
|
||||
double hl = high - low; // 7
|
||||
double hPc = Math.Abs(high - prevClose); // 5
|
||||
double lPc = Math.Abs(low - prevClose); // 2
|
||||
|
||||
double expectedTR = Math.Max(hl, Math.Max(hPc, lPc)); // 7
|
||||
|
||||
Assert.Equal(7.0, expectedTR);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vr_TrueRangeWithGapUp_MatchesPineScript()
|
||||
{
|
||||
// Gap up scenario: High-PrevClose is largest
|
||||
double prevClose = 100.0;
|
||||
double high = 110.0;
|
||||
double low = 108.0;
|
||||
|
||||
double hl = high - low; // 2
|
||||
double hPc = Math.Abs(high - prevClose); // 10
|
||||
double lPc = Math.Abs(low - prevClose); // 8
|
||||
|
||||
double expectedTR = Math.Max(hl, Math.Max(hPc, lPc)); // 10
|
||||
|
||||
Assert.Equal(10.0, expectedTR);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vr_TrueRangeWithGapDown_MatchesPineScript()
|
||||
{
|
||||
// Gap down scenario: Low-PrevClose (abs) is largest
|
||||
double prevClose = 100.0;
|
||||
double high = 92.0;
|
||||
double low = 90.0;
|
||||
|
||||
double hl = high - low; // 2
|
||||
double hPc = Math.Abs(high - prevClose); // 8
|
||||
double lPc = Math.Abs(low - prevClose); // 10
|
||||
|
||||
double expectedTR = Math.Max(hl, Math.Max(hPc, lPc)); // 10
|
||||
|
||||
Assert.Equal(10.0, expectedTR);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vr_BiasCorrection_MatchesPineScript()
|
||||
{
|
||||
// Verify bias correction formula: atr = rawAtr / (1 - eComp)
|
||||
// where eComp = (1 - alpha)^n for n bars
|
||||
|
||||
int period = 10;
|
||||
double alpha = 1.0 / period;
|
||||
|
||||
// After 1 bar: eComp = 0.9
|
||||
double eComp1 = 1.0 - alpha;
|
||||
Assert.Equal(0.9, eComp1, 10);
|
||||
|
||||
// After 2 bars: eComp = 0.81
|
||||
double eComp2 = (1.0 - alpha) * eComp1;
|
||||
Assert.Equal(0.81, eComp2, 10);
|
||||
|
||||
// After 3 bars: eComp = 0.729
|
||||
double eComp3 = (1.0 - alpha) * eComp2;
|
||||
Assert.Equal(0.729, eComp3, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vr_ConstantTR_ConvergesToOne()
|
||||
{
|
||||
// When TR is constant, VR = TR / ATR should approach 1.0
|
||||
// because ATR converges to TR
|
||||
|
||||
var vr = new Vr(period: 10);
|
||||
|
||||
// Feed bars with constant TR (H-L = 4)
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
vr.Update(new TBar(DateTime.UtcNow, 100, 102, 98, 100, 1000));
|
||||
}
|
||||
|
||||
// VR should be very close to 1.0
|
||||
Assert.True(Math.Abs(vr.Last.Value - 1.0) < 0.01,
|
||||
$"Constant TR should yield VR near 1.0, got {vr.Last.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vr_Formula_MatchesPineScript()
|
||||
{
|
||||
// VR = TR / ATR
|
||||
// With bias-corrected ATR (period = 14 in typical usage)
|
||||
|
||||
double tr = 5.0;
|
||||
double rawAtr = 4.0;
|
||||
double eComp = 0.5; // Example compensator
|
||||
|
||||
double atr = rawAtr / (1.0 - eComp); // = 4.0 / 0.5 = 8.0
|
||||
double expectedVr = tr / atr; // = 5.0 / 8.0 = 0.625
|
||||
|
||||
Assert.Equal(0.625, expectedVr, 10);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Streaming vs Batch Consistency
|
||||
|
||||
[Fact]
|
||||
public void Vr_StreamingMatchesBatch_AllPeriods()
|
||||
{
|
||||
int[] periods = [5, 10, 14, 20, 50];
|
||||
|
||||
foreach (int period in periods)
|
||||
{
|
||||
var bars = GenerateBarData(100);
|
||||
|
||||
// Streaming
|
||||
var streamingVr = new Vr(period);
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
streamingVr.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
// Batch
|
||||
double[] batchOutput = new double[bars.Count];
|
||||
Vr.Batch(bars, batchOutput, period);
|
||||
|
||||
// Compare final value
|
||||
Assert.Equal(streamingVr.Last.Value, batchOutput[bars.Count - 1], PineScriptTolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vr_BatchMatchesCalculate_AllValues()
|
||||
{
|
||||
var bars = GenerateBarData(100);
|
||||
int period = 14;
|
||||
|
||||
// Using static Calculate
|
||||
var calculateResult = Vr.Calculate(bars, period);
|
||||
|
||||
// Using Batch
|
||||
double[] batchOutput = new double[bars.Count];
|
||||
Vr.Batch(bars, batchOutput, period);
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
Assert.Equal(calculateResult[i].Value, batchOutput[i], PineScriptTolerance);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mathematical Properties
|
||||
|
||||
[Fact]
|
||||
public void Vr_AlwaysNonNegative()
|
||||
{
|
||||
var bars = GenerateBarData(500);
|
||||
var vr = new Vr(14);
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
var result = vr.Update(bars[i]);
|
||||
Assert.True(result.Value >= 0, $"VR at index {i} should be non-negative: {result.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vr_FirstBar_HasValidValue()
|
||||
{
|
||||
var vr = new Vr(14);
|
||||
var bar = new TBar(DateTime.UtcNow, 100, 105, 95, 102, 1000);
|
||||
|
||||
var result = vr.Update(bar);
|
||||
|
||||
// First bar: TR = H-L = 10, ATR = TR = 10, VR = 1.0
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
Assert.True(result.Value >= 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vr_HighVolatilityBar_ExceedsOne()
|
||||
{
|
||||
var vr = new Vr(period: 10);
|
||||
|
||||
// Build up ATR with low volatility
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
vr.Update(new TBar(DateTime.UtcNow, 100, 101, 99, 100, 1000));
|
||||
}
|
||||
|
||||
// Now add a high volatility bar
|
||||
var highVolBar = new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000);
|
||||
var result = vr.Update(highVolBar);
|
||||
|
||||
Assert.True(result.Value > 1.0,
|
||||
$"High volatility bar should produce VR > 1.0, got {result.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vr_LowVolatilityBar_BelowOne()
|
||||
{
|
||||
var vr = new Vr(period: 10);
|
||||
|
||||
// Build up ATR with moderate volatility
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
vr.Update(new TBar(DateTime.UtcNow, 100, 105, 95, 100, 1000));
|
||||
}
|
||||
|
||||
// Now add a low volatility bar
|
||||
var lowVolBar = new TBar(DateTime.UtcNow, 100, 100.5, 99.5, 100, 1000);
|
||||
var result = vr.Update(lowVolBar);
|
||||
|
||||
Assert.True(result.Value < 1.0,
|
||||
$"Low volatility bar should produce VR < 1.0, got {result.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vr_MeanRevertsToOne()
|
||||
{
|
||||
var vr = new Vr(period: 10);
|
||||
double sumVr = 0;
|
||||
int count = 0;
|
||||
|
||||
// Generate many bars
|
||||
var bars = GenerateBarData(500);
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
var result = vr.Update(bars[i]);
|
||||
if (vr.IsHot)
|
||||
{
|
||||
sumVr += result.Value;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
double avgVr = sumVr / count;
|
||||
|
||||
// Average VR should be near 1.0 over time
|
||||
Assert.True(avgVr > 0.5 && avgVr < 2.0,
|
||||
$"Average VR should be near 1.0, got {avgVr}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edge Cases
|
||||
|
||||
[Fact]
|
||||
public void Vr_Period1_HandlesCorrectly()
|
||||
{
|
||||
var vr = new Vr(1);
|
||||
var bar = new TBar(DateTime.UtcNow, 100, 105, 95, 102, 1000);
|
||||
|
||||
var result = vr.Update(bar);
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
Assert.True(result.Value >= 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vr_LargePeriod_HandlesCorrectly()
|
||||
{
|
||||
var vr = new Vr(200);
|
||||
var bars = GenerateBarData(300);
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
var result = vr.Update(bars[i]);
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
Assert.True(result.Value >= 0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vr_ZeroRange_HandlesCorrectly()
|
||||
{
|
||||
var vr = new Vr(10);
|
||||
|
||||
// Build up some ATR
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
vr.Update(new TBar(DateTime.UtcNow, 100, 105, 95, 100, 1000));
|
||||
}
|
||||
|
||||
// Zero range bar
|
||||
var zeroRangeBar = new TBar(DateTime.UtcNow, 100, 100, 100, 100, 1000);
|
||||
var result = vr.Update(zeroRangeBar);
|
||||
|
||||
// VR should be 0 when TR is 0
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
Assert.True(result.Value < 0.01, $"Zero TR should produce VR near 0, got {result.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vr_GapUp_IncorporatedInTR()
|
||||
{
|
||||
var vr = new Vr(period: 10);
|
||||
|
||||
// Establish baseline
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
vr.Update(new TBar(DateTime.UtcNow, 100, 101, 99, 100, 1000));
|
||||
}
|
||||
|
||||
// Gap up bar: previous close = 100, open = 110
|
||||
var gapBar = new TBar(DateTime.UtcNow, 110, 112, 109, 111, 1000);
|
||||
var result = vr.Update(gapBar);
|
||||
|
||||
// TR should include gap (High - PrevClose = 12)
|
||||
Assert.True(result.Value > 1.0,
|
||||
$"Gap up should produce VR > 1.0, got {result.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vr_GapDown_IncorporatedInTR()
|
||||
{
|
||||
var vr = new Vr(period: 10);
|
||||
|
||||
// Establish baseline
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
vr.Update(new TBar(DateTime.UtcNow, 100, 101, 99, 100, 1000));
|
||||
}
|
||||
|
||||
// Gap down bar: previous close = 100, open = 90
|
||||
var gapBar = new TBar(DateTime.UtcNow, 90, 91, 88, 89, 1000);
|
||||
var result = vr.Update(gapBar);
|
||||
|
||||
// TR should include gap (abs(Low - PrevClose) = 12)
|
||||
Assert.True(result.Value > 1.0,
|
||||
$"Gap down should produce VR > 1.0, got {result.Value}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Breakout Detection Tests
|
||||
|
||||
[Fact]
|
||||
public void Vr_BreakoutDetection_HighVRIndicatesBreakout()
|
||||
{
|
||||
var vr = new Vr(period: 14);
|
||||
|
||||
// Low volatility consolidation
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
vr.Update(new TBar(DateTime.UtcNow, 100, 101, 99, 100 + (i % 2) * 0.5, 1000));
|
||||
}
|
||||
|
||||
double consolidationVr = vr.Last.Value;
|
||||
|
||||
// Breakout bar
|
||||
var breakoutBar = new TBar(DateTime.UtcNow, 100, 115, 100, 114, 1000);
|
||||
var breakoutResult = vr.Update(breakoutBar);
|
||||
|
||||
Assert.True(breakoutResult.Value > 2.0,
|
||||
$"Breakout bar should produce VR > 2.0, got {breakoutResult.Value}");
|
||||
Assert.True(breakoutResult.Value > consolidationVr * 2,
|
||||
$"Breakout VR ({breakoutResult.Value}) should be much higher than consolidation VR ({consolidationVr})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Vr_VolatilityExpansion_Detected()
|
||||
{
|
||||
var vr = new Vr(period: 14);
|
||||
|
||||
// Track VR during expansion
|
||||
var vrValues = new List<double>();
|
||||
|
||||
// Start with low volatility
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var result = vr.Update(new TBar(DateTime.UtcNow, 100, 101, 99, 100, 1000));
|
||||
vrValues.Add(result.Value);
|
||||
}
|
||||
|
||||
// Gradually increase volatility
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
double range = 1 + i * 0.5;
|
||||
var result = vr.Update(new TBar(DateTime.UtcNow, 100, 100 + range, 100 - range, 100, 1000));
|
||||
vrValues.Add(result.Value);
|
||||
}
|
||||
|
||||
// Later VR values should be higher during expansion
|
||||
double earlyAvg = vrValues.Skip(15).Take(5).Average();
|
||||
double lateAvg = vrValues.Skip(35).Take(5).Average();
|
||||
|
||||
Assert.True(lateAvg > earlyAvg,
|
||||
$"Expanding volatility should show increasing VR: early={earlyAvg}, late={lateAvg}");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
// Volatility Ratio (VR) Indicator
|
||||
// Measures True Range relative to Average True Range to identify volatility breakouts
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// VR: Volatility Ratio
|
||||
/// Calculates the ratio of True Range to Average True Range.
|
||||
/// Values above 1.0 indicate higher-than-average volatility; below 1.0 indicates lower.
|
||||
/// Uses bias-corrected RMA for ATR calculation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Calculation steps:</b>
|
||||
/// <list type="number">
|
||||
/// <item>Calculate True Range: max(H-L, |H-PrevClose|, |L-PrevClose|)</item>
|
||||
/// <item>Calculate ATR using bias-corrected RMA</item>
|
||||
/// <item>VR = TR / ATR</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <b>Key characteristics:</b>
|
||||
/// <list type="bullet">
|
||||
/// <item>Values greater than 1.0 indicate current volatility exceeds average</item>
|
||||
/// <item>Values less than 1.0 indicate current volatility below average</item>
|
||||
/// <item>Useful for breakout detection and volatility regime changes</item>
|
||||
/// <item>Bias-corrected RMA provides accurate results during warmup</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Vr : AbstractBase
|
||||
{
|
||||
private readonly int _period;
|
||||
private const double Epsilon = 1e-10;
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(
|
||||
double RawAtr,
|
||||
double ECompensator,
|
||||
double PrevClose,
|
||||
double LastValidVr,
|
||||
int Count,
|
||||
bool HasPrevClose
|
||||
);
|
||||
private State _s;
|
||||
private State _ps;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the Vr class.
|
||||
/// </summary>
|
||||
/// <param name="period">The ATR lookback period (default 14).</param>
|
||||
/// <exception cref="ArgumentException">Thrown when period is less than 1.</exception>
|
||||
public Vr(int period = 14)
|
||||
{
|
||||
if (period <= 0)
|
||||
{
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
}
|
||||
_period = period;
|
||||
WarmupPeriod = period;
|
||||
Name = $"Vr({period})";
|
||||
_s = new State(0, 1.0, 0, 0, 0, false);
|
||||
_ps = _s;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the Vr class with a TBarSeries source.
|
||||
/// </summary>
|
||||
/// <param name="source">The data source for priming.</param>
|
||||
/// <param name="period">The ATR lookback period (default 14).</param>
|
||||
public Vr(TBarSeries source, int period = 14) : this(period)
|
||||
{
|
||||
// Prime with historical data
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Update(source[i], isNew: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if the indicator has enough data for valid results.
|
||||
/// </summary>
|
||||
public override bool IsHot => _s.Count >= _period;
|
||||
|
||||
/// <summary>
|
||||
/// The ATR lookback period.
|
||||
/// </summary>
|
||||
public int Period => _period;
|
||||
|
||||
/// <summary>
|
||||
/// Updates the indicator with a new bar.
|
||||
/// </summary>
|
||||
/// <param name="bar">The input bar (OHLC required).</param>
|
||||
/// <param name="isNew">Whether this is a new bar or an update.</param>
|
||||
/// <returns>The calculated VR value.</returns>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TBar bar, bool isNew = true)
|
||||
{
|
||||
if (isNew)
|
||||
{
|
||||
_ps = _s;
|
||||
}
|
||||
else
|
||||
{
|
||||
_s = _ps;
|
||||
}
|
||||
|
||||
var s = _s;
|
||||
|
||||
double high = bar.High;
|
||||
double low = bar.Low;
|
||||
double close = bar.Close;
|
||||
|
||||
// Handle non-finite values
|
||||
if (!double.IsFinite(high) || !double.IsFinite(low) || !double.IsFinite(close))
|
||||
{
|
||||
Last = new TValue(bar.Time, s.LastValidVr);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
// Calculate True Range
|
||||
double tr;
|
||||
double hl = high - low;
|
||||
|
||||
if (s.HasPrevClose)
|
||||
{
|
||||
double hPc = Math.Abs(high - s.PrevClose);
|
||||
double lPc = Math.Abs(low - s.PrevClose);
|
||||
tr = Math.Max(hl, Math.Max(hPc, lPc));
|
||||
}
|
||||
else
|
||||
{
|
||||
tr = hl;
|
||||
}
|
||||
|
||||
// Bias-corrected RMA for ATR
|
||||
double alpha = 1.0 / _period;
|
||||
double rawAtr;
|
||||
double eComp;
|
||||
|
||||
if (s.Count == 0)
|
||||
{
|
||||
// First bar: initialize with TR
|
||||
rawAtr = tr;
|
||||
eComp = 1.0 - alpha;
|
||||
}
|
||||
else
|
||||
{
|
||||
// RMA update: (prev * (period-1) + value) / period
|
||||
rawAtr = (s.RawAtr * (_period - 1) + tr) / _period;
|
||||
eComp = (1.0 - alpha) * s.ECompensator;
|
||||
}
|
||||
|
||||
// Bias correction
|
||||
double atr = eComp > Epsilon ? rawAtr / (1.0 - eComp) : rawAtr;
|
||||
|
||||
// Calculate VR = TR / ATR
|
||||
double vr = atr > Epsilon ? tr / atr : 0;
|
||||
|
||||
if (!double.IsFinite(vr) || vr < 0)
|
||||
{
|
||||
vr = s.LastValidVr;
|
||||
}
|
||||
else
|
||||
{
|
||||
s.LastValidVr = vr;
|
||||
}
|
||||
|
||||
// Update state
|
||||
s.RawAtr = rawAtr;
|
||||
s.ECompensator = eComp;
|
||||
if (isNew)
|
||||
{
|
||||
s.PrevClose = close;
|
||||
s.HasPrevClose = true;
|
||||
s.Count = Math.Min(s.Count + 1, _period);
|
||||
}
|
||||
|
||||
_s = s;
|
||||
|
||||
Last = new TValue(bar.Time, vr);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the indicator with a TValue input (uses value as all OHLC).
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
// Create a synthetic bar with the same OHLC (TR will be 0 for single values)
|
||||
var bar = new TBar(input.Time, input.Value, input.Value, input.Value, input.Value, 0);
|
||||
return Update(bar, isNew);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the indicator with a TBarSeries.
|
||||
/// </summary>
|
||||
public TSeries Update(TBarSeries source)
|
||||
{
|
||||
int len = source.Count;
|
||||
var t = new List<long>(len);
|
||||
var v = new List<double>(len);
|
||||
CollectionsMarshal.SetCount(t, len);
|
||||
CollectionsMarshal.SetCount(v, len);
|
||||
|
||||
var tSpan = CollectionsMarshal.AsSpan(t);
|
||||
var vSpan = CollectionsMarshal.AsSpan(v);
|
||||
|
||||
// Use batch calculation
|
||||
Batch(source, vSpan, _period);
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
tSpan[i] = source[i].Time;
|
||||
}
|
||||
|
||||
// Update internal state by replaying
|
||||
Reset();
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
Update(source[i], isNew: true);
|
||||
}
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override TSeries Update(TSeries source)
|
||||
{
|
||||
// For TSeries (price-only), create synthetic bars
|
||||
int len = source.Count;
|
||||
var t = new List<long>(len);
|
||||
var v = new List<double>(len);
|
||||
CollectionsMarshal.SetCount(t, len);
|
||||
CollectionsMarshal.SetCount(v, len);
|
||||
|
||||
Reset();
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
var result = Update(source[i], isNew: true);
|
||||
t[i] = result.Time;
|
||||
v[i] = result.Value;
|
||||
}
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
{
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
Update(new TValue(DateTime.UtcNow, source[i]), isNew: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Reset()
|
||||
{
|
||||
_s = new State(0, 1.0, 0, 0, 0, false);
|
||||
_ps = _s;
|
||||
Last = default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates VR for a TBarSeries (static).
|
||||
/// </summary>
|
||||
public static TSeries Calculate(TBarSeries source, int period = 14)
|
||||
{
|
||||
var vr = new Vr(period);
|
||||
return vr.Update(source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch calculation using spans.
|
||||
/// </summary>
|
||||
public static void Batch(
|
||||
TBarSeries source,
|
||||
Span<double> output,
|
||||
int period = 14)
|
||||
{
|
||||
if (period <= 0)
|
||||
{
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
}
|
||||
if (output.Length < source.Count)
|
||||
{
|
||||
throw new ArgumentException("Output span must be at least as long as source", nameof(output));
|
||||
}
|
||||
|
||||
int len = source.Count;
|
||||
if (len == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double rawAtr = 0;
|
||||
double eComp = 1.0;
|
||||
double alpha = 1.0 / period;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
var bar = source[i];
|
||||
double high = bar.High;
|
||||
double low = bar.Low;
|
||||
double close = bar.Close;
|
||||
|
||||
// Previous close (use close for first bar - no gap)
|
||||
double prevClose = i > 0 ? source[i - 1].Close : close;
|
||||
|
||||
// Calculate True Range
|
||||
double hl = high - low;
|
||||
double hPc = i > 0 ? Math.Abs(high - prevClose) : 0;
|
||||
double lPc = i > 0 ? Math.Abs(low - prevClose) : 0;
|
||||
double tr = i > 0 ? Math.Max(hl, Math.Max(hPc, lPc)) : hl;
|
||||
|
||||
// Bias-corrected RMA
|
||||
if (i == 0)
|
||||
{
|
||||
rawAtr = tr;
|
||||
eComp = 1.0 - alpha;
|
||||
}
|
||||
else
|
||||
{
|
||||
rawAtr = (rawAtr * (period - 1) + tr) / period;
|
||||
eComp = (1.0 - alpha) * eComp;
|
||||
}
|
||||
|
||||
double atr = eComp > Epsilon ? rawAtr / (1.0 - eComp) : rawAtr;
|
||||
|
||||
// Calculate VR
|
||||
double vr = atr > Epsilon ? tr / atr : 0;
|
||||
|
||||
if (!double.IsFinite(vr) || vr < 0)
|
||||
{
|
||||
vr = i > 0 ? output[i - 1] : 0;
|
||||
}
|
||||
|
||||
output[i] = vr;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch calculation for OHLC arrays.
|
||||
/// </summary>
|
||||
public static void Batch(
|
||||
ReadOnlySpan<double> high,
|
||||
ReadOnlySpan<double> low,
|
||||
ReadOnlySpan<double> close,
|
||||
Span<double> output,
|
||||
int period = 14)
|
||||
{
|
||||
if (period <= 0)
|
||||
{
|
||||
throw new ArgumentException("Period must be greater than 0", nameof(period));
|
||||
}
|
||||
int len = high.Length;
|
||||
if (low.Length < len || close.Length < len)
|
||||
{
|
||||
throw new ArgumentException("All HLC spans must have same length", nameof(low));
|
||||
}
|
||||
if (output.Length < len)
|
||||
{
|
||||
throw new ArgumentException("Output span must be at least as long as input", nameof(output));
|
||||
}
|
||||
|
||||
if (len == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double rawAtr = 0;
|
||||
double eComp = 1.0;
|
||||
double alpha = 1.0 / period;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
double h = high[i];
|
||||
double l = low[i];
|
||||
double c = close[i];
|
||||
double prevClose = i > 0 ? close[i - 1] : c;
|
||||
|
||||
double hl = h - l;
|
||||
double hPc = i > 0 ? Math.Abs(h - prevClose) : 0;
|
||||
double lPc = i > 0 ? Math.Abs(l - prevClose) : 0;
|
||||
double tr = i > 0 ? Math.Max(hl, Math.Max(hPc, lPc)) : hl;
|
||||
|
||||
if (i == 0)
|
||||
{
|
||||
rawAtr = tr;
|
||||
eComp = 1.0 - alpha;
|
||||
}
|
||||
else
|
||||
{
|
||||
rawAtr = (rawAtr * (period - 1) + tr) / period;
|
||||
eComp = (1.0 - alpha) * eComp;
|
||||
}
|
||||
|
||||
double atr = eComp > Epsilon ? rawAtr / (1.0 - eComp) : rawAtr;
|
||||
double vr = atr > Epsilon ? tr / atr : 0;
|
||||
|
||||
if (!double.IsFinite(vr) || vr < 0)
|
||||
{
|
||||
vr = i > 0 ? output[i - 1] : 0;
|
||||
}
|
||||
|
||||
output[i] = vr;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
# VR: Volatility Ratio
|
||||
|
||||
> "When today's range dwarfs the average, pay attention—the market is telling you something unusual is happening."
|
||||
|
||||
Volatility Ratio (VR) measures the current bar's True Range relative to its Average True Range (ATR), providing a normalized indicator of short-term volatility expansion or contraction. Values above 1.0 indicate above-average volatility (potential breakouts), while values below 1.0 suggest below-average volatility (consolidation). This simple yet powerful ratio helps traders identify when markets are moving unusually, often preceding significant price moves.
|
||||
|
||||
## Historical Context
|
||||
|
||||
The Volatility Ratio emerged from the practical need to normalize volatility readings across different market conditions and timeframes. While ATR (developed by J. Welles Wilder Jr. in 1978) provides an absolute measure of volatility, traders needed a relative measure to answer: "Is today's movement unusual compared to recent history?"
|
||||
|
||||
The ratio concept is straightforward: divide today's True Range by the average True Range. This normalization allows:
|
||||
1. Cross-market comparison (a VR of 2.0 means the same thing whether trading stocks, futures, or forex)
|
||||
2. Breakout detection (VR > threshold signals unusual movement)
|
||||
3. Volatility regime identification (sustained high/low VR indicates market character)
|
||||
|
||||
The implementation uses Wilder's RMA (also known as SMMA or modified EMA) with bias correction for accurate ATR calculation from the first bar.
|
||||
|
||||
## Architecture & Physics
|
||||
|
||||
### 1. True Range Calculation
|
||||
|
||||
True Range captures the full extent of price movement including gaps:
|
||||
|
||||
$$
|
||||
TR_t = \max(H_t - L_t, |H_t - C_{t-1}|, |L_t - C_{t-1}|)
|
||||
$$
|
||||
|
||||
where:
|
||||
- $H_t$ = Current high
|
||||
- $L_t$ = Current low
|
||||
- $C_{t-1}$ = Previous close
|
||||
|
||||
For the first bar (no previous close), TR = High - Low.
|
||||
|
||||
### 2. Bias-Corrected ATR (RMA)
|
||||
|
||||
ATR uses Wilder's smoothing (RMA) with bias correction:
|
||||
|
||||
$$
|
||||
\text{RMA}_{raw,t} = \alpha \cdot TR_t + (1 - \alpha) \cdot \text{RMA}_{raw,t-1}
|
||||
$$
|
||||
|
||||
where $\alpha = 1/\text{period}$.
|
||||
|
||||
**Bias compensator:**
|
||||
$$
|
||||
e_t = (1 - \alpha)^t
|
||||
$$
|
||||
|
||||
**Corrected ATR:**
|
||||
$$
|
||||
ATR_t = \frac{\text{RMA}_{raw,t}}{1 - e_t}
|
||||
$$
|
||||
|
||||
This correction eliminates the startup bias that would otherwise cause ATR to be understated during the warmup period.
|
||||
|
||||
### 3. Volatility Ratio
|
||||
|
||||
$$
|
||||
VR_t = \frac{TR_t}{ATR_t}
|
||||
$$
|
||||
|
||||
When ATR is near zero, VR returns 0 to avoid division by zero.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
### True Range Properties
|
||||
|
||||
True Range has three components to handle gaps:
|
||||
1. **H - L**: Intraday range (no gap)
|
||||
2. **|H - PrevClose|**: Gap up scenario (high extends above previous close)
|
||||
3. **|L - PrevClose|**: Gap down scenario (low extends below previous close)
|
||||
|
||||
The maximum of these three captures the full extent of price movement.
|
||||
|
||||
### RMA vs EMA
|
||||
|
||||
Wilder's RMA uses $\alpha = 1/n$ rather than EMA's $\alpha = 2/(n+1)$:
|
||||
|
||||
| Period | RMA α | EMA α | RMA Halflife | EMA Halflife |
|
||||
| :---: | :---: | :---: | :---: | :---: |
|
||||
| 14 | 0.0714 | 0.1333 | 9.6 bars | 4.8 bars |
|
||||
| 20 | 0.0500 | 0.0952 | 13.9 bars | 6.9 bars |
|
||||
|
||||
RMA is slower to respond, providing a more stable reference for the ratio.
|
||||
|
||||
### Example Calculation
|
||||
|
||||
Period = 3, Bars with previous close = 100:
|
||||
|
||||
| Bar | H | L | C | TR | RMA_raw | e | ATR | VR |
|
||||
| :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
|
||||
| 1 | 102 | 98 | 101 | 4.0 | 4.0 | 0.667 | 12.0 | 0.33 |
|
||||
| 2 | 106 | 100 | 105 | 6.0 | 4.67 | 0.444 | 8.40 | 0.71 |
|
||||
| 3 | 108 | 103 | 106 | 5.0 | 4.78 | 0.296 | 6.79 | 0.74 |
|
||||
| 4 | 115 | 104 | 112 | 9.0 | 6.19 | 0.198 | 7.72 | 1.17 |
|
||||
|
||||
Note: Bar 4 shows VR > 1.0, indicating above-average volatility.
|
||||
|
||||
## Performance Profile
|
||||
|
||||
### Operation Count (Streaming Mode, Scalar)
|
||||
|
||||
Per-bar operations:
|
||||
|
||||
| Operation | Count | Cost (cycles) | Subtotal |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| SUB | 3 | 1 | 3 |
|
||||
| ABS | 2 | 1 | 2 |
|
||||
| MAX | 2 | 2 | 4 |
|
||||
| MUL | 3 | 3 | 9 |
|
||||
| DIV | 2 | 15 | 30 |
|
||||
| FMA | 1 | 5 | 5 |
|
||||
| **Total** | — | — | **~53 cycles** |
|
||||
|
||||
Extremely lightweight—dominated by two divisions.
|
||||
|
||||
### Batch Mode (512 values, SIMD/FMA)
|
||||
|
||||
| Operation | Scalar Ops | SIMD Ops (AVX2) | Speedup |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| TR calculation | 3584 | 448 | 8× |
|
||||
| RMA smoothing | 1536 | N/A (recursive) | 1× |
|
||||
| Division | 1024 | 128 | 8× |
|
||||
|
||||
**Batch efficiency:**
|
||||
|
||||
| Mode | Cycles/bar | Total (512 bars) | Notes |
|
||||
| :--- | :---: | :---: | :--- |
|
||||
| Scalar streaming | ~53 | ~27k | Baseline |
|
||||
| Hybrid SIMD | ~35 | ~18k | TR vectorized, RMA scalar |
|
||||
| **Improvement** | **34%** | **9k saved** | Limited by RMA recursion |
|
||||
|
||||
### Memory Profile
|
||||
|
||||
- **Per instance:** ~72 bytes (state record + backup)
|
||||
- **100 instances:** ~7.2 KB
|
||||
- **No ring buffers**: RMA is fully recursive
|
||||
|
||||
### Quality Metrics
|
||||
|
||||
| Metric | Score | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| **Simplicity** | 10/10 | Single ratio, intuitive interpretation |
|
||||
| **Timeliness** | 10/10 | Immediate response to current bar |
|
||||
| **Stability** | 8/10 | ATR smoothing provides stable denominator |
|
||||
| **Signal Quality** | 8/10 | Clear breakout signals when VR > threshold |
|
||||
| **Cross-Market** | 9/10 | Normalized for comparison |
|
||||
|
||||
## Validation
|
||||
|
||||
| Library | Status | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| **TA-Lib** | N/A | Not implemented |
|
||||
| **Skender** | N/A | Not implemented |
|
||||
| **Tulip** | N/A | Not implemented |
|
||||
| **OoplesFinance** | N/A | Not implemented |
|
||||
| **PineScript** | ✅ | Matches vr.pine reference |
|
||||
| **Self-consistency** | ✅ | Streaming = Batch modes match |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **First bar handling**: On the first bar, there's no previous close. TR = H - L for this bar only, and ATR initialization uses bias correction to prevent understating early values.
|
||||
|
||||
2. **Warmup period**: VR needs approximately `Period` bars for ATR to stabilize. During warmup, bias correction helps but early readings may be less reliable. The implementation tracks warmup via `IsHot`.
|
||||
|
||||
3. **Threshold interpretation**: VR = 1.0 means "average" volatility. Common breakout thresholds:
|
||||
- VR > 1.5: Moderate breakout signal
|
||||
- VR > 2.0: Strong breakout signal
|
||||
- VR < 0.5: Extremely low volatility (consolidation)
|
||||
|
||||
4. **Denominator protection**: When ATR ≈ 0 (nearly flat market), the implementation returns 0 rather than causing division errors.
|
||||
|
||||
5. **Scale is relative**: VR = 2.0 always means "twice normal volatility" regardless of the underlying instrument's absolute price or typical ATR value.
|
||||
|
||||
6. **Period selection**: Shorter periods (7-10) make ATR more responsive, causing VR to spike less dramatically. Longer periods (20-30) create a more stable baseline, making VR spikes more pronounced.
|
||||
|
||||
## Trading Applications
|
||||
|
||||
### Breakout Detection
|
||||
|
||||
The primary use case—identify unusual volatility expansion:
|
||||
|
||||
```
|
||||
VR > 2.0: Potential breakout in progress
|
||||
VR > 1.5 && Volume > 2×Avg: High-conviction breakout
|
||||
VR < 0.7 sustained: Building energy for eventual breakout
|
||||
```
|
||||
|
||||
### Position Sizing
|
||||
|
||||
Scale position size inversely with current VR:
|
||||
|
||||
```
|
||||
Base Position × (Target_VR / Current_VR)
|
||||
Example: 1000 shares × (1.0 / 2.0) = 500 shares during high volatility
|
||||
```
|
||||
|
||||
### Stop Loss Adjustment
|
||||
|
||||
Widen stops when VR is elevated:
|
||||
|
||||
```
|
||||
Stop Distance = ATR × Multiplier × VR
|
||||
Higher VR → Wider stops to avoid noise
|
||||
```
|
||||
|
||||
### Volatility Squeeze Detection
|
||||
|
||||
Identify consolidation before expansion:
|
||||
|
||||
```
|
||||
VR < 0.6 for 5+ bars → Volatility squeeze
|
||||
Watch for VR breakout above 1.5 to signal expansion
|
||||
```
|
||||
|
||||
### Regime Classification
|
||||
|
||||
```
|
||||
VR < 0.7: Low volatility (trend following works)
|
||||
VR 0.7-1.3: Normal volatility (standard strategies)
|
||||
VR > 1.3: High volatility (reduce size, widen stops)
|
||||
VR > 2.0: Extreme volatility (defensive positioning)
|
||||
```
|
||||
|
||||
### Entry Timing
|
||||
|
||||
```
|
||||
Breakout entry: Wait for VR > 1.5 to confirm move
|
||||
Mean reversion: Enter when VR > 2.0 starts declining
|
||||
Trend following: Best when VR 1.0-1.5 (movement with stability)
|
||||
```
|
||||
|
||||
## Relationship to Other Indicators
|
||||
|
||||
| Indicator | Relationship to VR |
|
||||
| :--- | :--- |
|
||||
| **ATR** | VR = TR/ATR; VR normalizes ATR for comparison |
|
||||
| **NATR** | NATR = ATR/Close×100; VR uses TR ratio instead |
|
||||
| **Bollinger Width** | Both measure volatility; VR uses TR, BB uses std dev |
|
||||
| **Keltner Width** | KC uses ATR; VR provides ratio view of same data |
|
||||
| **ADX** | ADX measures trend strength; VR measures volatility expansion |
|
||||
| **ATRP** | ATRP = ATR/Close×100; VR = TR/ATR |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### State Management
|
||||
|
||||
The indicator maintains a compact state record:
|
||||
- `RawAtr`: Running RMA value (before bias correction)
|
||||
- `ECompensator`: Bias compensator $(1-\alpha)^n$
|
||||
- `PrevClose`: Previous bar's close for True Range
|
||||
- `LastValidVr`: Last valid output for NaN handling
|
||||
- `Count`: Bar count for warmup tracking
|
||||
- `HasPrevClose`: Flag for first-bar handling
|
||||
|
||||
### NaN/Infinity Handling
|
||||
|
||||
Invalid HLC inputs are detected and the last valid VR is substituted. This prevents NaN propagation through the calculation chain.
|
||||
|
||||
### Numerical Stability
|
||||
|
||||
The implementation uses:
|
||||
- Epsilon guard (1e-10) for ATR division safety
|
||||
- Zero return when ATR < epsilon
|
||||
- Last-valid substitution for non-finite results
|
||||
|
||||
## References
|
||||
|
||||
- Wilder, J. W. (1978). *New Concepts in Technical Trading Systems*. Trend Research.
|
||||
- Kaufman, P. J. (2013). *Trading Systems and Methods* (5th ed.). John Wiley & Sons.
|
||||
- Kirkpatrick, C. D., & Dahlquist, J. R. (2010). *Technical Analysis: The Complete Resource for Financial Market Technicians* (2nd ed.). FT Press.
|
||||
Reference in New Issue
Block a user