mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-17 18:18:04 +00:00
more volatilty
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using QuanTAlib;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class RviIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void RviIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new RviIndicator();
|
||||
|
||||
Assert.Equal(10, indicator.StdevLength);
|
||||
Assert.Equal(14, indicator.RmaLength);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("RVI - Relative Volatility Index", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RviIndicator_ShortName_IncludesParameters()
|
||||
{
|
||||
var indicator = new RviIndicator { StdevLength = 10, RmaLength = 14 };
|
||||
Assert.Contains("RVI", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("10", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("14", indicator.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RviIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new RviIndicator();
|
||||
|
||||
Assert.Equal(0, RviIndicator.MinHistoryDepths);
|
||||
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RviIndicator_Initialize_CreatesInternalRvi()
|
||||
{
|
||||
var indicator = new RviIndicator();
|
||||
|
||||
// Initialize should not throw
|
||||
indicator.Initialize();
|
||||
|
||||
// After init, line series should exist
|
||||
Assert.Single(indicator.LinesSeries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RviIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new RviIndicator { StdevLength = 10, RmaLength = 14 };
|
||||
indicator.Initialize();
|
||||
|
||||
// Add historical data with trending prices
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
double closePrice = 100 + i * 0.5 + Math.Sin(i * 0.3) * 2;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), closePrice - 1, closePrice + 2, closePrice - 2, closePrice, 1000);
|
||||
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(val));
|
||||
Assert.True(val >= 0 && val <= 100, "RVI should be in range [0,100]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RviIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new RviIndicator { StdevLength = 10, RmaLength = 14 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
double closePrice = 100 + i * 0.3;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), closePrice - 1, closePrice + 2, closePrice - 2, closePrice, 1000);
|
||||
}
|
||||
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
// Add new bar
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(50), 115, 120, 110, 118, 1500);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
Assert.Equal(2, indicator.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RviIndicator_DifferentStdevLengths_Work()
|
||||
{
|
||||
int[] lengths = { 5, 10, 14, 20 };
|
||||
|
||||
foreach (var length in lengths)
|
||||
{
|
||||
var indicator = new RviIndicator { StdevLength = length, RmaLength = 14 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 60; i++)
|
||||
{
|
||||
double closePrice = 100 + i * 0.2 + Math.Sin(i * 0.5) * 3;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), closePrice - 1, closePrice + 2, closePrice - 2, closePrice, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(val), $"StdevLength {length} should produce finite value");
|
||||
Assert.True(val >= 0 && val <= 100, $"StdevLength {length} should produce value in [0,100]");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RviIndicator_DifferentRmaLengths_Work()
|
||||
{
|
||||
int[] lengths = { 7, 14, 20, 28 };
|
||||
|
||||
foreach (var length in lengths)
|
||||
{
|
||||
var indicator = new RviIndicator { StdevLength = 10, RmaLength = length };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 60; i++)
|
||||
{
|
||||
double closePrice = 100 + i * 0.2 + Math.Sin(i * 0.5) * 3;
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), closePrice - 1, closePrice + 2, closePrice - 2, closePrice, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(val), $"RmaLength {length} should produce finite value");
|
||||
Assert.True(val >= 0 && val <= 100, $"RmaLength {length} should produce value in [0,100]");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RviIndicator_StdevLength_CanBeChanged()
|
||||
{
|
||||
var indicator = new RviIndicator();
|
||||
Assert.Equal(10, indicator.StdevLength);
|
||||
|
||||
indicator.StdevLength = 14;
|
||||
Assert.Equal(14, indicator.StdevLength);
|
||||
|
||||
indicator.StdevLength = 20;
|
||||
Assert.Equal(20, indicator.StdevLength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RviIndicator_RmaLength_CanBeChanged()
|
||||
{
|
||||
var indicator = new RviIndicator();
|
||||
Assert.Equal(14, indicator.RmaLength);
|
||||
|
||||
indicator.RmaLength = 10;
|
||||
Assert.Equal(10, indicator.RmaLength);
|
||||
|
||||
indicator.RmaLength = 21;
|
||||
Assert.Equal(21, indicator.RmaLength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RviIndicator_ShowColdValues_CanBeToggled()
|
||||
{
|
||||
var indicator = new RviIndicator();
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
|
||||
indicator.ShowColdValues = false;
|
||||
Assert.False(indicator.ShowColdValues);
|
||||
|
||||
indicator.ShowColdValues = true;
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RviIndicator_SourceCodeLink_IsValid()
|
||||
{
|
||||
var indicator = new RviIndicator();
|
||||
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
Assert.Contains("Rvi.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RviIndicator_Uptrend_ProducesHighValue()
|
||||
{
|
||||
var indicator = new RviIndicator { StdevLength = 10, RmaLength = 14 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Strong uptrend: price consistently rising
|
||||
for (int i = 0; i < 60; i++)
|
||||
{
|
||||
double closePrice = 100 + i * 1.5; // Strong consistent uptrend
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), closePrice - 1, closePrice + 2, closePrice - 2, closePrice, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(val));
|
||||
Assert.True(val > 50, $"Strong uptrend should produce RVI > 50, got {val}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RviIndicator_Downtrend_ProducesLowValue()
|
||||
{
|
||||
var indicator = new RviIndicator { StdevLength = 10, RmaLength = 14 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Strong downtrend: price consistently falling
|
||||
for (int i = 0; i < 60; i++)
|
||||
{
|
||||
double closePrice = 200 - i * 1.5; // Strong consistent downtrend
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), closePrice - 1, closePrice + 2, closePrice - 2, closePrice, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(val));
|
||||
Assert.True(val < 50, $"Strong downtrend should produce RVI < 50, got {val}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RviIndicator_ValueRange_IsBounded()
|
||||
{
|
||||
var indicator = new RviIndicator { StdevLength = 10, RmaLength = 14 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Mixed data with various price movements
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double closePrice = 100 + Math.Sin(i * 0.2) * 20 + (i % 3 == 0 ? 5 : -3);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), closePrice - 2, closePrice + 3, closePrice - 3, closePrice, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
if (indicator.LinesSeries[0].Count > 0)
|
||||
{
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
if (double.IsFinite(val))
|
||||
{
|
||||
Assert.True(val >= 0, $"RVI should be >= 0, got {val} at bar {i}");
|
||||
Assert.True(val <= 100, $"RVI should be <= 100, got {val} at bar {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RviIndicator_UsesClosePrice()
|
||||
{
|
||||
// RVI should use close prices for direction determination
|
||||
var indicator1 = new RviIndicator { StdevLength = 10, RmaLength = 14 };
|
||||
var indicator2 = new RviIndicator { StdevLength = 10, RmaLength = 14 };
|
||||
indicator1.Initialize();
|
||||
indicator2.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Same close prices, different open/high/low
|
||||
for (int i = 0; i < 60; i++)
|
||||
{
|
||||
double closePrice = 100 + i * 0.5;
|
||||
// Indicator 1: narrow range
|
||||
indicator1.HistoricalData.AddBar(now.AddMinutes(i), closePrice, closePrice + 1, closePrice - 1, closePrice, 1000);
|
||||
indicator1.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
// Indicator 2: wide range (same close)
|
||||
indicator2.HistoricalData.AddBar(now.AddMinutes(i), closePrice - 5, closePrice + 10, closePrice - 10, closePrice, 1000);
|
||||
indicator2.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double val1 = indicator1.LinesSeries[0].GetValue(0);
|
||||
double val2 = indicator2.LinesSeries[0].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(val1));
|
||||
Assert.True(double.IsFinite(val2));
|
||||
// RVI primarily depends on close-to-close direction, so values should be similar
|
||||
Assert.True(Math.Abs(val1 - val2) < 5, $"RVI values should be similar for same closes: {val1} vs {val2}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RviIndicator_NeutralMarket_ProducesNearFifty()
|
||||
{
|
||||
var indicator = new RviIndicator { StdevLength = 10, RmaLength = 14 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Alternating up/down with equal magnitude
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
double closePrice = 100 + (i % 2 == 0 ? 2 : -2);
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), closePrice - 1, closePrice + 1, closePrice - 1, closePrice, 1000);
|
||||
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
}
|
||||
|
||||
double val = indicator.LinesSeries[0].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(val));
|
||||
// In a neutral market, RVI should be near 50
|
||||
Assert.True(val >= 30 && val <= 70, $"Neutral market should produce RVI near 50, got {val}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class RviIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("StdDev Length", sortIndex: 1, 2, 100, 1, 0)]
|
||||
public int StdevLength { get; set; } = 10;
|
||||
|
||||
[InputParameter("RMA Length", sortIndex: 2, 1, 100, 1, 0)]
|
||||
public int RmaLength { get; set; } = 14;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Rvi _rvi = null!;
|
||||
private readonly LineSeries _series;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"RVI({StdevLength},{RmaLength})";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/volatility/rvi/Rvi.Quantower.cs";
|
||||
|
||||
public RviIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
Name = "RVI - Relative Volatility Index";
|
||||
Description = "Relative Volatility Index measures the direction of volatility by comparing upward and downward price movements weighted by their standard deviations";
|
||||
|
||||
_series = new LineSeries(name: "RVI", color: IndicatorExtensions.Volatility, width: 2, style: LineStyle.Solid);
|
||||
AddLineSeries(_series);
|
||||
}
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
_rvi = new Rvi(StdevLength, RmaLength);
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
TBar bar = this.GetInputBar(args);
|
||||
TValue result = _rvi.Update(bar, isNew: args.IsNewBar());
|
||||
_series.SetValue(result.Value, _rvi.IsHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
// RVI Unit Tests
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class RviTests
|
||||
{
|
||||
private readonly GBM _gbm;
|
||||
private const int DefaultStdevLength = 10;
|
||||
private const int DefaultRmaLength = 14;
|
||||
private const double Tolerance = 1e-10;
|
||||
|
||||
public RviTests()
|
||||
{
|
||||
_gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
}
|
||||
|
||||
private TBarSeries GenerateBars(int count)
|
||||
{
|
||||
_gbm.Reset(DateTime.UtcNow.Ticks);
|
||||
return _gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
private static TSeries GeneratePriceSeries(int count, int seed = 42)
|
||||
{
|
||||
var gbm = new GBM(seed: seed);
|
||||
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var t = new List<long>(count);
|
||||
var v = new List<double>(count);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
t.Add(bars[i].Time);
|
||||
v.Add(bars[i].Close);
|
||||
}
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
#region Constructor Tests
|
||||
|
||||
[Fact]
|
||||
public void Constructor_DefaultParameters_SetsCorrectValues()
|
||||
{
|
||||
var rvi = new Rvi();
|
||||
Assert.Equal(DefaultStdevLength, rvi.StdevLength);
|
||||
Assert.Equal(DefaultRmaLength, rvi.RmaLength);
|
||||
Assert.Equal($"Rvi({DefaultStdevLength},{DefaultRmaLength})", rvi.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_CustomParameters_SetsCorrectValues()
|
||||
{
|
||||
var rvi = new Rvi(stdevLength: 20, rmaLength: 21);
|
||||
Assert.Equal(20, rvi.StdevLength);
|
||||
Assert.Equal(21, rvi.RmaLength);
|
||||
Assert.Equal("Rvi(20,21)", rvi.Name);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(0)]
|
||||
[InlineData(-5)]
|
||||
public void Constructor_InvalidStdevLength_ThrowsArgumentException(int stdevLength)
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Rvi(stdevLength: stdevLength));
|
||||
Assert.Equal("stdevLength", ex.ParamName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public void Constructor_InvalidRmaLength_ThrowsArgumentException(int rmaLength)
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Rvi(stdevLength: 10, rmaLength: rmaLength));
|
||||
Assert.Equal("rmaLength", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_WithSource_SubscribesToEvents()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var rvi = new Rvi(source, stdevLength: 10, rmaLength: 14);
|
||||
source.Add(new TValue(DateTime.UtcNow, 100.0));
|
||||
Assert.NotEqual(default, rvi.Last);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Basic Calculation Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_FirstValue_ReturnsNeutral()
|
||||
{
|
||||
var rvi = new Rvi();
|
||||
var result = rvi.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
Assert.Equal(50.0, result.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ReturnsValidTValue()
|
||||
{
|
||||
var rvi = new Rvi();
|
||||
var time = DateTime.UtcNow;
|
||||
rvi.Update(new TValue(time.AddSeconds(-1), 100.0));
|
||||
var result = rvi.Update(new TValue(time, 101.0));
|
||||
Assert.Equal(time.Ticks, result.Time);
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_WithTBar_UsesClosePrice()
|
||||
{
|
||||
var rvi = new Rvi();
|
||||
var bar = new TBar(DateTime.UtcNow, 98, 102, 97, 100, 1000);
|
||||
var result = rvi.Update(bar);
|
||||
Assert.Equal(50.0, result.Value, Tolerance); // First value
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_OutputRangeIsZeroToHundred()
|
||||
{
|
||||
var rvi = new Rvi(stdevLength: 5, rmaLength: 5);
|
||||
var bars = GenerateBars(500);
|
||||
|
||||
for (int i = 0; i < 500; i++)
|
||||
{
|
||||
var result = rvi.Update(new TValue(bars[i].Time, bars[i].Close));
|
||||
Assert.InRange(result.Value, 0.0, 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ConsistentUpTrend_ProducesHighValues()
|
||||
{
|
||||
var rvi = new Rvi(stdevLength: 5, rmaLength: 10);
|
||||
|
||||
// Consistent up moves
|
||||
double price = 100.0;
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
price += 1.0; // Always up
|
||||
rvi.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price));
|
||||
}
|
||||
|
||||
// Should be above 50 (bullish)
|
||||
Assert.True(rvi.Last.Value > 50.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_ConsistentDownTrend_ProducesLowValues()
|
||||
{
|
||||
var rvi = new Rvi(stdevLength: 5, rmaLength: 10);
|
||||
|
||||
// Consistent down moves
|
||||
double price = 200.0;
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
price -= 1.0; // Always down
|
||||
rvi.Update(new TValue(DateTime.UtcNow.AddSeconds(i), price));
|
||||
}
|
||||
|
||||
// Should be below 50 (bearish)
|
||||
Assert.True(rvi.Last.Value < 50.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_NoChange_StaysNeutral()
|
||||
{
|
||||
var rvi = new Rvi(stdevLength: 5, rmaLength: 10);
|
||||
|
||||
// Constant price - no direction
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
rvi.Update(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0));
|
||||
}
|
||||
|
||||
// Should approach neutral (50)
|
||||
Assert.InRange(rvi.Last.Value, 40.0, 60.0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsHot and WarmupPeriod Tests
|
||||
|
||||
[Fact]
|
||||
public void IsHot_BeforeWarmup_ReturnsFalse()
|
||||
{
|
||||
var rvi = new Rvi(stdevLength: 10, rmaLength: 14);
|
||||
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
rvi.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
Assert.False(rvi.IsHot);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_AfterWarmup_ReturnsTrue()
|
||||
{
|
||||
var rvi = new Rvi(stdevLength: 10, rmaLength: 14);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
rvi.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
}
|
||||
|
||||
Assert.True(rvi.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmupPeriod_EqualsStdevLengthPlusRmaLength()
|
||||
{
|
||||
var rvi = new Rvi(stdevLength: 10, rmaLength: 14);
|
||||
Assert.Equal(24, rvi.WarmupPeriod);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region State and Bar Correction Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewTrue_AdvancesState()
|
||||
{
|
||||
var rvi = new Rvi();
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
rvi.Update(new TValue(time.AddSeconds(-2), 100.0), isNew: true);
|
||||
rvi.Update(new TValue(time.AddSeconds(-1), 101.0), isNew: true);
|
||||
var val1 = rvi.Update(new TValue(time, 102.0), isNew: true);
|
||||
|
||||
rvi.Update(new TValue(time.AddSeconds(-1), 101.0), isNew: true);
|
||||
var val2 = rvi.Update(new TValue(time.AddSeconds(1), 102.0), isNew: true);
|
||||
|
||||
// Different sequence should produce different result
|
||||
Assert.NotEqual(val1.Value, val2.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewFalse_RollsBackState()
|
||||
{
|
||||
var rvi = new Rvi();
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Build up some history
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
rvi.Update(new TValue(time.AddSeconds(i), 100.0 + i * 0.1), isNew: true);
|
||||
}
|
||||
|
||||
_ = rvi.Last; // Capture state before update
|
||||
|
||||
// New bar
|
||||
var result1 = rvi.Update(new TValue(time.AddSeconds(20), 105.0), isNew: true);
|
||||
|
||||
// Update same bar with different value - should rollback
|
||||
var result2 = rvi.Update(new TValue(time.AddSeconds(20), 106.0), isNew: false);
|
||||
|
||||
// Different input should produce different result
|
||||
Assert.NotEqual(result1.Value, result2.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IterativeCorrections_RestoreState()
|
||||
{
|
||||
var rvi = new Rvi(stdevLength: 5, rmaLength: 10);
|
||||
var time = DateTime.UtcNow;
|
||||
|
||||
// Build history
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
rvi.Update(new TValue(time.AddSeconds(i), 100.0 + i * 0.5), isNew: true);
|
||||
}
|
||||
|
||||
// Start a new bar
|
||||
var newBarValue = rvi.Update(new TValue(time.AddSeconds(30), 120.0), isNew: true);
|
||||
|
||||
// Multiple corrections
|
||||
_ = rvi.Update(new TValue(time.AddSeconds(30), 121.0), isNew: false);
|
||||
_ = rvi.Update(new TValue(time.AddSeconds(30), 122.0), isNew: false);
|
||||
var correction3 = rvi.Update(new TValue(time.AddSeconds(30), 120.0), isNew: false);
|
||||
|
||||
// Going back to original value should restore original result
|
||||
Assert.Equal(newBarValue.Value, correction3.Value, Tolerance);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reset Tests
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var rvi = new Rvi();
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
rvi.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
}
|
||||
|
||||
Assert.True(rvi.IsHot);
|
||||
|
||||
rvi.Reset();
|
||||
|
||||
Assert.False(rvi.IsHot);
|
||||
Assert.Equal(default, rvi.Last);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_AllowsReuseOfIndicator()
|
||||
{
|
||||
var rvi = new Rvi();
|
||||
|
||||
// First run
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
rvi.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
}
|
||||
var firstResult = rvi.Last;
|
||||
|
||||
rvi.Reset();
|
||||
|
||||
// Second run with same data
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
rvi.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
}
|
||||
var secondResult = rvi.Last;
|
||||
|
||||
Assert.Equal(firstResult.Value, secondResult.Value, Tolerance);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region NaN and Infinity Handling Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_NaNInput_UsesLastValidValue()
|
||||
{
|
||||
var rvi = new Rvi();
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
rvi.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
}
|
||||
var validValue = rvi.Last;
|
||||
|
||||
var nanResult = rvi.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
|
||||
Assert.Equal(validValue.Value, nanResult.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_InfinityInput_UsesLastValidValue()
|
||||
{
|
||||
var rvi = new Rvi();
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
rvi.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
}
|
||||
var validValue = rvi.Last;
|
||||
|
||||
var infResult = rvi.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
||||
|
||||
Assert.Equal(validValue.Value, infResult.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_NegativeInfinityInput_UsesLastValidValue()
|
||||
{
|
||||
var rvi = new Rvi();
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
rvi.Update(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
}
|
||||
var validValue = rvi.Last;
|
||||
|
||||
var negInfResult = rvi.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity));
|
||||
|
||||
Assert.Equal(validValue.Value, negInfResult.Value, Tolerance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_WithNaN_ProducesSafeOutput()
|
||||
{
|
||||
double[] prices = [100.0, 101.0, double.NaN, 103.0, 104.0, 105.0, 106.0, 107.0, 108.0, 109.0, 110.0];
|
||||
double[] output = new double[prices.Length];
|
||||
|
||||
Rvi.Batch(prices, output, stdevLength: 5, rmaLength: 5);
|
||||
|
||||
foreach (var val in output)
|
||||
{
|
||||
Assert.True(double.IsFinite(val));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mode Consistency Tests
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceConsistentResults()
|
||||
{
|
||||
const int dataLen = 200;
|
||||
var bars = GenerateBars(dataLen);
|
||||
|
||||
var prices = new double[dataLen];
|
||||
var times = new long[dataLen];
|
||||
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
prices[i] = bars[i].Close;
|
||||
times[i] = bars[i].Time;
|
||||
}
|
||||
|
||||
// Mode 1: Streaming
|
||||
var rvi1 = new Rvi(stdevLength: 10, rmaLength: 14);
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
rvi1.Update(new TValue(times[i], prices[i]), isNew: true);
|
||||
}
|
||||
|
||||
// Mode 2: Batch via TSeries
|
||||
var tSeries = new TSeries(new List<long>(times), new List<double>(prices));
|
||||
var batchResult = Rvi.Calculate(tSeries, stdevLength: 10, rmaLength: 14);
|
||||
|
||||
// Mode 3: Span-based
|
||||
double[] spanOutput = new double[dataLen];
|
||||
Rvi.Batch(prices, spanOutput, stdevLength: 10, rmaLength: 14);
|
||||
|
||||
// Mode 4: Event-driven
|
||||
var sourceSeries = new TSeries();
|
||||
var rviEvent = new Rvi(sourceSeries, stdevLength: 10, rmaLength: 14);
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
sourceSeries.Add(new TValue(times[i], prices[i]));
|
||||
}
|
||||
|
||||
// Compare last 100 values
|
||||
int compareStart = dataLen - 100;
|
||||
for (int i = compareStart; i < dataLen; i++)
|
||||
{
|
||||
double batch = batchResult[i].Value;
|
||||
double span = spanOutput[i];
|
||||
|
||||
// Batch and Span should match exactly
|
||||
Assert.Equal(batch, span, Tolerance);
|
||||
}
|
||||
|
||||
// Final values should match
|
||||
Assert.Equal(rvi1.Last.Value, batchResult[dataLen - 1].Value, 1e-8);
|
||||
Assert.Equal(rvi1.Last.Value, spanOutput[dataLen - 1], 1e-8);
|
||||
Assert.Equal(rvi1.Last.Value, rviEvent.Last.Value, 1e-8);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Span API Tests
|
||||
|
||||
[Fact]
|
||||
public void Batch_ValidatesOutputLength()
|
||||
{
|
||||
double[] prices = [100.0, 101.0, 102.0, 103.0, 104.0];
|
||||
double[] output = new double[3]; // Too short
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => Rvi.Batch(prices, output));
|
||||
Assert.Equal("output", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_ValidatesStdevLength()
|
||||
{
|
||||
double[] prices = [100.0, 101.0, 102.0];
|
||||
double[] output = new double[3];
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => Rvi.Batch(prices, output, stdevLength: 1));
|
||||
Assert.Equal("stdevLength", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_ValidatesRmaLength()
|
||||
{
|
||||
double[] prices = [100.0, 101.0, 102.0];
|
||||
double[] output = new double[3];
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => Rvi.Batch(prices, output, stdevLength: 2, rmaLength: 0));
|
||||
Assert.Equal("rmaLength", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_EmptyInput_ProducesNoOutput()
|
||||
{
|
||||
double[] prices = [];
|
||||
double[] output = [];
|
||||
|
||||
Rvi.Batch(prices, output);
|
||||
// Should not throw, and output remains empty
|
||||
Assert.Empty(output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_MatchesStreamingMode()
|
||||
{
|
||||
const int dataLen = 100;
|
||||
var bars = GenerateBars(dataLen);
|
||||
|
||||
var prices = new double[dataLen];
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
prices[i] = bars[i].Close;
|
||||
}
|
||||
|
||||
// Streaming
|
||||
var rvi = new Rvi(stdevLength: 10, rmaLength: 14);
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
rvi.Update(new TValue(bars[i].Time, prices[i]));
|
||||
}
|
||||
|
||||
// Batch
|
||||
double[] batchOutput = new double[dataLen];
|
||||
Rvi.Batch(prices, batchOutput, stdevLength: 10, rmaLength: 14);
|
||||
|
||||
// Compare final value
|
||||
Assert.Equal(rvi.Last.Value, batchOutput[dataLen - 1], 1e-8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_LargeDataset_NoStackOverflow()
|
||||
{
|
||||
const int dataLen = 10000;
|
||||
double[] prices = new double[dataLen];
|
||||
double[] output = new double[dataLen];
|
||||
|
||||
// Fill with realistic data
|
||||
double price = 100.0;
|
||||
var rng = new Random(42);
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
price *= 1.0 + (rng.NextDouble() - 0.5) * 0.02;
|
||||
prices[i] = price;
|
||||
}
|
||||
|
||||
Rvi.Batch(prices, output, stdevLength: 10, rmaLength: 14);
|
||||
|
||||
// Verify all outputs are valid
|
||||
for (int i = 0; i < dataLen; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(output[i]));
|
||||
Assert.InRange(output[i], 0.0, 100.0);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Chainability Tests
|
||||
|
||||
[Fact]
|
||||
public void Pub_FiresOnUpdate()
|
||||
{
|
||||
var rvi = new Rvi();
|
||||
int eventCount = 0;
|
||||
|
||||
rvi.Pub += (object? sender, in TValueEventArgs args) => eventCount++;
|
||||
|
||||
rvi.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
rvi.Update(new TValue(DateTime.UtcNow, 101.0));
|
||||
rvi.Update(new TValue(DateTime.UtcNow, 102.0));
|
||||
|
||||
Assert.Equal(3, eventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventChaining_Works()
|
||||
{
|
||||
var sourceSeries = new TSeries();
|
||||
var rvi = new Rvi(sourceSeries, stdevLength: 5, rmaLength: 10);
|
||||
|
||||
var results = new List<double>();
|
||||
rvi.Pub += (object? sender, in TValueEventArgs args) => results.Add(args.Value.Value);
|
||||
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
sourceSeries.Add(new TValue(DateTime.UtcNow, 100.0 + i));
|
||||
}
|
||||
|
||||
Assert.Equal(30, results.Count);
|
||||
Assert.All(results.ToArray(), r => Assert.InRange(r, 0.0, 100.0));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region TSeries and TBarSeries Tests
|
||||
|
||||
[Fact]
|
||||
public void Update_TSeries_ReturnsCorrectLength()
|
||||
{
|
||||
var rvi = new Rvi();
|
||||
var source = new TSeries();
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
source.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i));
|
||||
}
|
||||
|
||||
var result = rvi.Update(source);
|
||||
|
||||
Assert.Equal(50, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_TBarSeries_ReturnsCorrectLength()
|
||||
{
|
||||
var rvi = new Rvi();
|
||||
var source = new TBarSeries();
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var time = DateTime.UtcNow.AddSeconds(i);
|
||||
double price = 100.0 + i;
|
||||
source.Add(new TBar(time, price - 1, price + 1, price - 2, price, 1000));
|
||||
}
|
||||
|
||||
var result = rvi.Update(source);
|
||||
|
||||
Assert.Equal(50, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Static_TSeries_Works()
|
||||
{
|
||||
var source = new TSeries();
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
source.Add(new TValue(DateTime.UtcNow.AddSeconds(i), 100.0 + i * 0.5));
|
||||
}
|
||||
|
||||
var result = Rvi.Calculate(source, stdevLength: 10, rmaLength: 14);
|
||||
|
||||
Assert.Equal(50, result.Count);
|
||||
// Allow small floating-point tolerance beyond [0,100]
|
||||
Assert.All(result.Values.ToArray(), v => Assert.InRange(v, -1e-9, 100.0 + 1e-9));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_Static_TBarSeries_Works()
|
||||
{
|
||||
var source = new TBarSeries();
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var time = DateTime.UtcNow.AddSeconds(i);
|
||||
double price = 100.0 + i * 0.5;
|
||||
source.Add(new TBar(time, price - 1, price + 1, price - 2, price, 1000));
|
||||
}
|
||||
|
||||
var result = Rvi.Calculate(source, stdevLength: 10, rmaLength: 14);
|
||||
|
||||
Assert.Equal(50, result.Count);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Prime Tests
|
||||
|
||||
[Fact]
|
||||
public void Prime_SetsInitialState()
|
||||
{
|
||||
var rvi = new Rvi(stdevLength: 5, rmaLength: 10);
|
||||
double[] warmupData = [100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114];
|
||||
|
||||
rvi.Prime(warmupData);
|
||||
|
||||
Assert.True(rvi.IsHot);
|
||||
Assert.True(rvi.Last.Value > 0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
namespace QuanTAlib.Test;
|
||||
|
||||
using Xunit;
|
||||
|
||||
/// <summary>
|
||||
/// Validation tests for RVI (Relative Volatility Index).
|
||||
/// RVI measures the direction of volatility using standard deviation weighted by price direction.
|
||||
/// Formula: RVI = 100 × avgUpStd / (avgUpStd + avgDownStd)
|
||||
/// Uses population stddev over rolling window and RMA smoothing with bias correction.
|
||||
/// </summary>
|
||||
public class RviValidationTests
|
||||
{
|
||||
private static TBarSeries GenerateTestData(int count = 100)
|
||||
{
|
||||
var gbm = new GBM(seed: 42);
|
||||
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
private static TSeries GeneratePriceSeries(int count = 100)
|
||||
{
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var t = new List<long>(count);
|
||||
var v = new List<double>(count);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
t.Add(bars[i].Time);
|
||||
v.Add(bars[i].Close);
|
||||
}
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
// === Mathematical Validation ===
|
||||
|
||||
/// <summary>
|
||||
/// Validates population standard deviation formula: σ = √(E[X²] - E[X]²)
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_PopulationStdDevFormula_IsCorrect()
|
||||
{
|
||||
// Known values: 1, 2, 3, 4, 5
|
||||
double[] values = { 1, 2, 3, 4, 5 };
|
||||
double sum = 0, sumSq = 0;
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
sum += values[i];
|
||||
sumSq += values[i] * values[i];
|
||||
}
|
||||
double mean = sum / values.Length;
|
||||
double variance = (sumSq / values.Length) - (mean * mean);
|
||||
double stdDev = Math.Sqrt(variance);
|
||||
|
||||
// Expected: mean = 3, E[X²] = (1+4+9+16+25)/5 = 11
|
||||
// Var = 11 - 9 = 2, StdDev = √2 ≈ 1.414
|
||||
Assert.Equal(Math.Sqrt(2.0), stdDev, 10);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates RMA (Wilder's smoothing) formula: raw = (raw * (length - 1) + value) / length
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_RmaFormula_IsCorrect()
|
||||
{
|
||||
int length = 14;
|
||||
double[] values = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14 };
|
||||
double raw = 0;
|
||||
|
||||
for (int i = 0; i < values.Length; i++)
|
||||
{
|
||||
raw = ((raw * (length - 1)) + values[i]) / length;
|
||||
}
|
||||
|
||||
// After 14 values with RMA(14), verify the smoothing effect
|
||||
Assert.True(raw > 0);
|
||||
Assert.True(raw < 14); // Should be smoothed below max
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates RMA bias correction formula: result = e > ε ? raw / (1 - e) : raw
|
||||
/// where e = (1 - alpha) * e_prev, starting at 1.0
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_BiasCorrection_IsCorrect()
|
||||
{
|
||||
int length = 14;
|
||||
double alpha = 1.0 / length;
|
||||
double e = 1.0;
|
||||
|
||||
// After one iteration
|
||||
e = (1 - alpha) * e;
|
||||
double correctionFactor1 = 1.0 / (1.0 - e);
|
||||
Assert.True(correctionFactor1 > 1.0, "First correction factor should amplify");
|
||||
|
||||
// After many iterations, e approaches 0
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
e = (1 - alpha) * e;
|
||||
}
|
||||
double correctionFactorN = 1.0 / (1.0 - e);
|
||||
Assert.True(correctionFactorN < 1.01, "After warmup, correction factor approaches 1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates RVI formula: RVI = 100 × avgUpStd / (avgUpStd + avgDownStd)
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(10.0, 10.0, 50.0)] // Equal up/down = neutral
|
||||
[InlineData(20.0, 10.0, 66.666666666666666)] // More up = bullish
|
||||
[InlineData(10.0, 20.0, 33.333333333333333)] // More down = bearish
|
||||
[InlineData(100.0, 0.0, 100.0)] // All up = max bullish
|
||||
[InlineData(0.0, 100.0, 0.0)] // All down = max bearish
|
||||
public void Rvi_RatioFormula_IsCorrect(double avgUpStd, double avgDownStd, double expectedRvi)
|
||||
{
|
||||
double rvi = (avgUpStd + avgDownStd) > 1e-10
|
||||
? 100.0 * avgUpStd / (avgUpStd + avgDownStd)
|
||||
: 50.0;
|
||||
Assert.Equal(expectedRvi, rvi, 6);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates RVI oscillator range is bounded [0, 100].
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_Output_IsBounded()
|
||||
{
|
||||
var prices = GeneratePriceSeries(200);
|
||||
var rvi = new Rvi(10, 14);
|
||||
|
||||
for (int i = 0; i < prices.Count; i++)
|
||||
{
|
||||
rvi.Update(prices[i]);
|
||||
if (rvi.IsHot)
|
||||
{
|
||||
Assert.True(rvi.Last.Value >= 0.0 && rvi.Last.Value <= 100.0,
|
||||
$"RVI should be in [0,100], got {rvi.Last.Value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that constant prices produce neutral RVI (50).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_ConstantPrices_ProducesNeutralValue()
|
||||
{
|
||||
var rvi = new Rvi(10, 14);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
rvi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0));
|
||||
}
|
||||
|
||||
// With no price changes, both up and down are 0, should return neutral 50
|
||||
Assert.Equal(50.0, rvi.Last.Value, 6);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that strictly rising prices produce high RVI (approaching 100).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_StrictlyRisingPrices_ProducesHighValue()
|
||||
{
|
||||
var rvi = new Rvi(10, 14);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
rvi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + i * 0.5));
|
||||
}
|
||||
|
||||
Assert.True(rvi.Last.Value > 80.0, $"Strictly rising prices should produce high RVI, got {rvi.Last.Value}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that strictly falling prices produce low RVI (approaching 0).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_StrictlyFallingPrices_ProducesLowValue()
|
||||
{
|
||||
var rvi = new Rvi(10, 14);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
rvi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 - i * 0.5));
|
||||
}
|
||||
|
||||
Assert.True(rvi.Last.Value < 20.0, $"Strictly falling prices should produce low RVI, got {rvi.Last.Value}");
|
||||
}
|
||||
|
||||
// === Consistency Tests ===
|
||||
|
||||
/// <summary>
|
||||
/// Validates streaming and batch produce identical results.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_StreamingMatchesBatch()
|
||||
{
|
||||
var prices = GeneratePriceSeries(100);
|
||||
|
||||
// Streaming calculation
|
||||
var streamingRvi = new Rvi(10, 14);
|
||||
for (int i = 0; i < prices.Count; i++)
|
||||
{
|
||||
streamingRvi.Update(prices[i]);
|
||||
}
|
||||
|
||||
// Batch calculation
|
||||
var batchResult = Rvi.Calculate(prices, 10, 14);
|
||||
|
||||
// Compare last values
|
||||
Assert.Equal(batchResult.Last.Value, streamingRvi.Last.Value, 8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates TSeries input matches TValue streaming.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_TSeriesInput_MatchesStreaming()
|
||||
{
|
||||
var prices = GeneratePriceSeries(100);
|
||||
|
||||
// Streaming
|
||||
var streamingRvi = new Rvi(10, 14);
|
||||
for (int i = 0; i < prices.Count; i++)
|
||||
{
|
||||
streamingRvi.Update(prices[i]);
|
||||
}
|
||||
|
||||
// TSeries batch
|
||||
var batchRvi = new Rvi(10, 14);
|
||||
var batchResult = batchRvi.Update(prices);
|
||||
|
||||
Assert.Equal(batchResult.Last.Value, streamingRvi.Last.Value, 10);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates Span batch matches streaming.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_SpanBatch_MatchesStreaming()
|
||||
{
|
||||
var prices = GeneratePriceSeries(100);
|
||||
|
||||
// Streaming
|
||||
var streamingRvi = new Rvi(10, 14);
|
||||
for (int i = 0; i < prices.Count; i++)
|
||||
{
|
||||
streamingRvi.Update(prices[i]);
|
||||
}
|
||||
|
||||
// Span batch
|
||||
var output = new double[prices.Count];
|
||||
Rvi.Batch(prices.Values, output, 10, 14);
|
||||
|
||||
Assert.Equal(output[^1], streamingRvi.Last.Value, 10);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates TBar update uses only Close price.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_TBar_UsesOnlyClose()
|
||||
{
|
||||
var bars = GenerateTestData(50);
|
||||
|
||||
// Using TBar
|
||||
var rviBar = new Rvi(10, 14);
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
rviBar.Update(bars[i]);
|
||||
}
|
||||
|
||||
// Using just Close prices
|
||||
var rviClose = new Rvi(10, 14);
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
rviClose.Update(new TValue(bars[i].Time, bars[i].Close));
|
||||
}
|
||||
|
||||
Assert.Equal(rviClose.Last.Value, rviBar.Last.Value, 10);
|
||||
}
|
||||
|
||||
// === Parameter Sensitivity ===
|
||||
|
||||
/// <summary>
|
||||
/// Validates shorter stddev period produces more responsive RVI.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_ShorterStdevPeriod_MoreResponsive()
|
||||
{
|
||||
var prices = GeneratePriceSeries(100);
|
||||
|
||||
var rviShort = new Rvi(stdevLength: 5, rmaLength: 14);
|
||||
var rviLong = new Rvi(stdevLength: 20, rmaLength: 14);
|
||||
|
||||
var shortResults = new List<double>();
|
||||
var longResults = new List<double>();
|
||||
|
||||
for (int i = 0; i < prices.Count; i++)
|
||||
{
|
||||
rviShort.Update(prices[i]);
|
||||
rviLong.Update(prices[i]);
|
||||
|
||||
if (rviShort.IsHot && rviLong.IsHot)
|
||||
{
|
||||
shortResults.Add(rviShort.Last.Value);
|
||||
longResults.Add(rviLong.Last.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// Shorter period should have higher variance in results
|
||||
double shortVar = Variance(shortResults);
|
||||
double longVar = Variance(longResults);
|
||||
|
||||
Assert.True(shortResults.Count > 0, "Should have hot results");
|
||||
Assert.True(shortVar > longVar * 0.8,
|
||||
"Shorter stddev period should generally be more variable");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates shorter RMA period produces faster response.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_ShorterRmaPeriod_FasterResponse()
|
||||
{
|
||||
var prices = GeneratePriceSeries(100);
|
||||
|
||||
var rviFast = new Rvi(stdevLength: 10, rmaLength: 7);
|
||||
var rviSlow = new Rvi(stdevLength: 10, rmaLength: 21);
|
||||
|
||||
var fastResults = new List<double>();
|
||||
var slowResults = new List<double>();
|
||||
|
||||
for (int i = 0; i < prices.Count; i++)
|
||||
{
|
||||
rviFast.Update(prices[i]);
|
||||
rviSlow.Update(prices[i]);
|
||||
|
||||
if (rviFast.IsHot && rviSlow.IsHot)
|
||||
{
|
||||
fastResults.Add(rviFast.Last.Value);
|
||||
slowResults.Add(rviSlow.Last.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// Faster RMA should have higher variance
|
||||
double fastVar = Variance(fastResults);
|
||||
double slowVar = Variance(slowResults);
|
||||
|
||||
Assert.True(fastResults.Count > 0, "Should have hot results");
|
||||
Assert.True(fastVar > slowVar * 0.8,
|
||||
"Faster RMA should generally be more variable");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates different parameters produce different results.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_DifferentParameters_ProduceDifferentResults()
|
||||
{
|
||||
var prices = GeneratePriceSeries(50);
|
||||
|
||||
var rvi1 = new Rvi(10, 14);
|
||||
var rvi2 = new Rvi(5, 14);
|
||||
var rvi3 = new Rvi(10, 7);
|
||||
|
||||
for (int i = 0; i < prices.Count; i++)
|
||||
{
|
||||
rvi1.Update(prices[i]);
|
||||
rvi2.Update(prices[i]);
|
||||
rvi3.Update(prices[i]);
|
||||
}
|
||||
|
||||
Assert.NotEqual(rvi1.Last.Value, rvi2.Last.Value);
|
||||
Assert.NotEqual(rvi1.Last.Value, rvi3.Last.Value);
|
||||
}
|
||||
|
||||
// === Edge Cases ===
|
||||
|
||||
/// <summary>
|
||||
/// Validates handling of very small price changes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_VerySmallChanges_HandledCorrectly()
|
||||
{
|
||||
var rvi = new Rvi(10, 14);
|
||||
|
||||
double price = 100.0;
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
price += 0.0001 * (i % 2 == 0 ? 1 : -1); // Tiny oscillation
|
||||
rvi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price));
|
||||
}
|
||||
|
||||
Assert.True(double.IsFinite(rvi.Last.Value));
|
||||
Assert.True(rvi.Last.Value >= 0 && rvi.Last.Value <= 100);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates handling of large price swings.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_LargePriceSwings_HandledCorrectly()
|
||||
{
|
||||
var rvi = new Rvi(10, 14);
|
||||
|
||||
double price = 100.0;
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
price *= (i % 2 == 0 ? 1.1 : 0.9); // 10% swings
|
||||
rvi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price));
|
||||
}
|
||||
|
||||
Assert.True(double.IsFinite(rvi.Last.Value));
|
||||
Assert.True(rvi.Last.Value >= 0 && rvi.Last.Value <= 100);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates warmup period calculation (stdevLength + rmaLength).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(10, 14, 24)]
|
||||
[InlineData(5, 7, 12)]
|
||||
[InlineData(20, 20, 40)]
|
||||
public void Rvi_WarmupPeriod_IsCorrect(int stdevLength, int rmaLength, int expectedWarmup)
|
||||
{
|
||||
var rvi = new Rvi(stdevLength, rmaLength);
|
||||
Assert.Equal(expectedWarmup, rvi.WarmupPeriod);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates bar correction works correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_BarCorrection_WorksCorrectly()
|
||||
{
|
||||
var rvi = new Rvi(10, 14);
|
||||
var prices = GeneratePriceSeries(40);
|
||||
|
||||
// Feed initial prices
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
rvi.Update(prices[i], isNew: true);
|
||||
}
|
||||
|
||||
// Add new price
|
||||
rvi.Update(prices[30], isNew: true);
|
||||
double afterNew = rvi.Last.Value;
|
||||
|
||||
// Correct with very different price
|
||||
var correctedPrice = new TValue(prices[30].Time, prices[30].Value * 1.5);
|
||||
rvi.Update(correctedPrice, isNew: false);
|
||||
double afterCorrection = rvi.Last.Value;
|
||||
|
||||
// Restore original
|
||||
rvi.Update(prices[30], isNew: false);
|
||||
double afterRestore = rvi.Last.Value;
|
||||
|
||||
Assert.NotEqual(afterNew, afterCorrection);
|
||||
Assert.Equal(afterNew, afterRestore, 10);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates iterative corrections converge to same result.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_IterativeCorrections_Converge()
|
||||
{
|
||||
var rvi = new Rvi(10, 14);
|
||||
var prices = GeneratePriceSeries(40);
|
||||
|
||||
// Feed prices and make corrections
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
rvi.Update(prices[i], isNew: true);
|
||||
}
|
||||
|
||||
// Multiple corrections on same price
|
||||
for (int j = 0; j < 5; j++)
|
||||
{
|
||||
var tempPrice = new TValue(prices[29].Time, prices[29].Value * (1.0 + j * 0.01));
|
||||
rvi.Update(tempPrice, isNew: false);
|
||||
}
|
||||
|
||||
// Final correction back to original
|
||||
rvi.Update(prices[29], isNew: false);
|
||||
double afterCorrections = rvi.Last.Value;
|
||||
|
||||
// Fresh calculation
|
||||
var rviFresh = new Rvi(10, 14);
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
rviFresh.Update(prices[i], isNew: true);
|
||||
}
|
||||
double freshValue = rviFresh.Last.Value;
|
||||
|
||||
Assert.Equal(freshValue, afterCorrections, 10);
|
||||
}
|
||||
|
||||
// === Behavioral Tests ===
|
||||
|
||||
/// <summary>
|
||||
/// Validates RVI responds to trend changes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_RespondsToTrendChange()
|
||||
{
|
||||
var rvi = new Rvi(10, 14);
|
||||
|
||||
// Uptrend phase
|
||||
double price = 100.0;
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
price += 0.5;
|
||||
rvi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price));
|
||||
}
|
||||
double afterUptrend = rvi.Last.Value;
|
||||
|
||||
// Downtrend phase
|
||||
for (int i = 50; i < 100; i++)
|
||||
{
|
||||
price -= 0.5;
|
||||
rvi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price));
|
||||
}
|
||||
double afterDowntrend = rvi.Last.Value;
|
||||
|
||||
Assert.True(afterUptrend > 60, "RVI should be high after uptrend");
|
||||
Assert.True(afterDowntrend < 40, "RVI should be low after downtrend");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates RVI stability over repeated runs with same seed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_Stability_ConsistentOverRepeatedRuns()
|
||||
{
|
||||
var results = new List<double>();
|
||||
|
||||
for (int run = 0; run < 3; run++)
|
||||
{
|
||||
var gbm = new GBM(seed: 42);
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var rvi = new Rvi(10, 14);
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
rvi.Update(bars[i]);
|
||||
}
|
||||
results.Add(rvi.Last.Value);
|
||||
}
|
||||
|
||||
Assert.Equal(results[0], results[1], 15);
|
||||
Assert.Equal(results[1], results[2], 15);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates RVI is in a reasonable range for oscillating prices.
|
||||
/// Note: RVI depends on the sequence of up/down moves. A sine wave doesn't
|
||||
/// guarantee neutral RVI because the direction changes occur at different
|
||||
/// phases relative to when volatility peaks.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_OscillatingPrices_StaysInRange()
|
||||
{
|
||||
var rvi = new Rvi(10, 14);
|
||||
|
||||
// Symmetric oscillation
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
double price = 100.0 + Math.Sin(i * 0.1) * 5; // Oscillating ±5
|
||||
rvi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price));
|
||||
}
|
||||
|
||||
// For oscillating data, RVI should stay within reasonable bounds
|
||||
// but doesn't necessarily hover at exactly 50
|
||||
Assert.True(rvi.Last.Value >= 0 && rvi.Last.Value <= 100,
|
||||
$"Oscillating prices should produce RVI in valid range, got {rvi.Last.Value}");
|
||||
Assert.True(double.IsFinite(rvi.Last.Value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates RVI produces reasonable values for typical market data.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_ProducesReasonableValues()
|
||||
{
|
||||
var prices = GeneratePriceSeries(200);
|
||||
var rvi = new Rvi(10, 14);
|
||||
|
||||
int validCount = 0;
|
||||
for (int i = 0; i < prices.Count; i++)
|
||||
{
|
||||
rvi.Update(prices[i]);
|
||||
if (rvi.IsHot)
|
||||
{
|
||||
validCount++;
|
||||
Assert.True(double.IsFinite(rvi.Last.Value));
|
||||
Assert.True(rvi.Last.Value >= 0 && rvi.Last.Value <= 100);
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(validCount > 100, "Should have many valid values");
|
||||
}
|
||||
|
||||
// === Helper Methods ===
|
||||
|
||||
private static double Variance(List<double> values)
|
||||
{
|
||||
if (values.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
double mean = values.Average();
|
||||
return values.Average(v => Math.Pow(v - mean, 2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
// Relative Volatility Index (RVI) Indicator
|
||||
// Measures the direction of volatility using standard deviation and RMA smoothing
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// RVI: Relative Volatility Index
|
||||
/// Measures the direction of volatility by comparing upward and downward price movements
|
||||
/// weighted by their standard deviations, smoothed with Wilder's RMA.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Calculation steps:</b>
|
||||
/// <list type="number">
|
||||
/// <item>Calculate population standard deviation of prices over stdevLength</item>
|
||||
/// <item>Classify by price change: if up, upStd = stddev; if down, downStd = stddev</item>
|
||||
/// <item>Smooth upStd and downStd with RMA (Wilder's smoothing with bias correction)</item>
|
||||
/// <item>RVI = 100 × avgUpStd / (avgUpStd + avgDownStd)</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <b>Key characteristics:</b>
|
||||
/// <list type="bullet">
|
||||
/// <item>Oscillator ranging from 0 to 100</item>
|
||||
/// <item>Values above 50 indicate upward volatility momentum</item>
|
||||
/// <item>Values below 50 indicate downward volatility momentum</item>
|
||||
/// <item>Often used to confirm RSI signals or as a standalone indicator</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <b>Sources:</b>
|
||||
/// Donald Dorsey (1993). "The Relative Volatility Index". Technical Analysis of Stocks & Commodities.
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Rvi : AbstractBase
|
||||
{
|
||||
private const double Epsilon = 1e-10;
|
||||
|
||||
private readonly int _stdevLength;
|
||||
private readonly int _rmaLength;
|
||||
private readonly double _alpha;
|
||||
private readonly RingBuffer _priceBuffer;
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(
|
||||
double PrevPrice,
|
||||
double Sum,
|
||||
double SumSq,
|
||||
double RawRmaUp,
|
||||
double EUp,
|
||||
double RawRmaDown,
|
||||
double EDown,
|
||||
double LastValue,
|
||||
int FillCount
|
||||
);
|
||||
private State _s;
|
||||
private State _ps;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the Rvi class.
|
||||
/// </summary>
|
||||
/// <param name="stdevLength">The lookback period for standard deviation calculation (default 10).</param>
|
||||
/// <param name="rmaLength">The lookback period for RMA smoothing (default 14).</param>
|
||||
/// <exception cref="ArgumentException">
|
||||
/// Thrown when stdevLength is less than 2, or rmaLength is less than 1.
|
||||
/// </exception>
|
||||
public Rvi(int stdevLength = 10, int rmaLength = 14)
|
||||
{
|
||||
if (stdevLength < 2)
|
||||
{
|
||||
throw new ArgumentException("Standard deviation length must be at least 2", nameof(stdevLength));
|
||||
}
|
||||
if (rmaLength < 1)
|
||||
{
|
||||
throw new ArgumentException("RMA length must be at least 1", nameof(rmaLength));
|
||||
}
|
||||
_stdevLength = stdevLength;
|
||||
_rmaLength = rmaLength;
|
||||
_alpha = 1.0 / rmaLength;
|
||||
_priceBuffer = new RingBuffer(stdevLength);
|
||||
WarmupPeriod = stdevLength + rmaLength;
|
||||
Name = $"Rvi({stdevLength},{rmaLength})";
|
||||
_s = new State(double.NaN, 0, 0, 0, 1.0, 0, 1.0, 50.0, 0);
|
||||
_ps = _s;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the Rvi class with a source.
|
||||
/// </summary>
|
||||
/// <param name="source">The data source for chaining.</param>
|
||||
/// <param name="stdevLength">The lookback period for standard deviation calculation (default 10).</param>
|
||||
/// <param name="rmaLength">The lookback period for RMA smoothing (default 14).</param>
|
||||
public Rvi(ITValuePublisher source, int stdevLength = 10, int rmaLength = 14)
|
||||
: this(stdevLength, rmaLength)
|
||||
{
|
||||
source.Pub += Handle;
|
||||
}
|
||||
|
||||
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
|
||||
|
||||
/// <summary>
|
||||
/// True if the indicator has enough data for valid results.
|
||||
/// </summary>
|
||||
public override bool IsHot => _s.FillCount >= _stdevLength;
|
||||
|
||||
/// <summary>
|
||||
/// The lookback period for standard deviation calculation.
|
||||
/// </summary>
|
||||
public int StdevLength => _stdevLength;
|
||||
|
||||
/// <summary>
|
||||
/// The lookback period for RMA smoothing.
|
||||
/// </summary>
|
||||
public int RmaLength => _rmaLength;
|
||||
|
||||
/// <summary>
|
||||
/// Updates the indicator with a new price value.
|
||||
/// </summary>
|
||||
/// <param name="input">The input price value.</param>
|
||||
/// <param name="isNew">Whether this is a new bar or an update.</param>
|
||||
/// <returns>The calculated RVI value.</returns>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
return UpdateCore(input.Time, input.Value, isNew);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the indicator with a new bar (uses Close price).
|
||||
/// </summary>
|
||||
/// <param name="bar">The input bar.</param>
|
||||
/// <param name="isNew">Whether this is a new bar or an update.</param>
|
||||
/// <returns>The calculated RVI value.</returns>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TBar bar, bool isNew = true)
|
||||
{
|
||||
return UpdateCore(bar.Time, bar.Close, isNew);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the indicator with a bar series.
|
||||
/// </summary>
|
||||
/// <param name="source">The source bar series.</param>
|
||||
/// <returns>A TSeries containing the RVI values.</returns>
|
||||
public TSeries Update(TBarSeries source)
|
||||
{
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Extract close prices
|
||||
Span<double> closes = len <= 128 ? stackalloc double[len] : new double[len];
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
closes[i] = source[i].Close;
|
||||
tSpan[i] = source[i].Time;
|
||||
}
|
||||
|
||||
Batch(closes, vSpan, _stdevLength, _rmaLength);
|
||||
|
||||
// Update internal state
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
Update(new TValue(source[i].Time, source[i].Close), isNew: true);
|
||||
}
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override TSeries Update(TSeries source)
|
||||
{
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Batch(source.Values, vSpan, _stdevLength, _rmaLength);
|
||||
source.Times.CopyTo(tSpan);
|
||||
|
||||
// Update internal state
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
Update(new TValue(source.Times[i], source.Values[i]), isNew: true);
|
||||
}
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private TValue UpdateCore(long timeTicks, double price, bool isNew)
|
||||
{
|
||||
if (isNew)
|
||||
{
|
||||
_ps = _s;
|
||||
_priceBuffer.Snapshot();
|
||||
}
|
||||
else
|
||||
{
|
||||
_s = _ps;
|
||||
_priceBuffer.Restore();
|
||||
}
|
||||
|
||||
var s = _s;
|
||||
|
||||
// Handle non-finite price
|
||||
if (!double.IsFinite(price))
|
||||
{
|
||||
Last = new TValue(timeTicks, s.LastValue);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
double rviValue;
|
||||
|
||||
// Need previous price for direction
|
||||
if (double.IsNaN(s.PrevPrice))
|
||||
{
|
||||
// First price - add to buffer but no RVI yet
|
||||
_priceBuffer.Add(price);
|
||||
s = s with
|
||||
{
|
||||
PrevPrice = price,
|
||||
Sum = price,
|
||||
SumSq = price * price,
|
||||
FillCount = 1
|
||||
};
|
||||
rviValue = 50.0; // Neutral
|
||||
}
|
||||
else
|
||||
{
|
||||
// Calculate price change direction
|
||||
double priceChange = price - s.PrevPrice;
|
||||
|
||||
// Update price buffer for stddev calculation
|
||||
double oldSum = s.Sum;
|
||||
double oldSumSq = s.SumSq;
|
||||
int oldCount = s.FillCount;
|
||||
|
||||
// Remove oldest if buffer full
|
||||
if (_priceBuffer.Count == _stdevLength)
|
||||
{
|
||||
double oldest = _priceBuffer[0];
|
||||
oldSum -= oldest;
|
||||
oldSumSq -= oldest * oldest;
|
||||
oldCount--;
|
||||
}
|
||||
|
||||
// Add new price
|
||||
_priceBuffer.Add(price);
|
||||
double newSum = oldSum + price;
|
||||
double newSumSq = oldSumSq + (price * price);
|
||||
int newCount = oldCount + 1;
|
||||
|
||||
// Calculate population stddev
|
||||
double currentStdDev = 0.0;
|
||||
if (newCount > 1)
|
||||
{
|
||||
double mean = newSum / newCount;
|
||||
double variance = (newSumSq / newCount) - (mean * mean);
|
||||
variance = Math.Max(0.0, variance);
|
||||
currentStdDev = Math.Sqrt(variance);
|
||||
}
|
||||
|
||||
// Classify stddev by direction
|
||||
double upStdVal = 0.0;
|
||||
double downStdVal = 0.0;
|
||||
|
||||
if (priceChange > 0)
|
||||
{
|
||||
upStdVal = currentStdDev;
|
||||
}
|
||||
else if (priceChange < 0)
|
||||
{
|
||||
downStdVal = currentStdDev;
|
||||
}
|
||||
// If priceChange == 0, both stay 0
|
||||
|
||||
// RMA with bias correction for upward stddev
|
||||
double rawRmaUp = s.RawRmaUp;
|
||||
double eUp = s.EUp;
|
||||
|
||||
rawRmaUp = Math.FusedMultiplyAdd(rawRmaUp, _rmaLength - 1, upStdVal) / _rmaLength;
|
||||
eUp = (1 - _alpha) * eUp;
|
||||
double avgUpStd = eUp > Epsilon ? rawRmaUp / (1.0 - eUp) : rawRmaUp;
|
||||
|
||||
// RMA with bias correction for downward stddev
|
||||
double rawRmaDown = s.RawRmaDown;
|
||||
double eDown = s.EDown;
|
||||
|
||||
rawRmaDown = Math.FusedMultiplyAdd(rawRmaDown, _rmaLength - 1, downStdVal) / _rmaLength;
|
||||
eDown = (1 - _alpha) * eDown;
|
||||
double avgDownStd = eDown > Epsilon ? rawRmaDown / (1.0 - eDown) : rawRmaDown;
|
||||
|
||||
// Calculate RVI
|
||||
double sumAvgStd = avgUpStd + avgDownStd;
|
||||
rviValue = sumAvgStd > Epsilon ? (100.0 * avgUpStd / sumAvgStd) : 50.0;
|
||||
|
||||
s = s with
|
||||
{
|
||||
PrevPrice = price,
|
||||
Sum = newSum,
|
||||
SumSq = newSumSq,
|
||||
RawRmaUp = rawRmaUp,
|
||||
EUp = eUp,
|
||||
RawRmaDown = rawRmaDown,
|
||||
EDown = eDown,
|
||||
FillCount = newCount
|
||||
};
|
||||
}
|
||||
|
||||
if (!double.IsFinite(rviValue))
|
||||
{
|
||||
rviValue = s.LastValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
s = s with { LastValue = rviValue };
|
||||
}
|
||||
|
||||
_s = s;
|
||||
|
||||
Last = new TValue(timeTicks, rviValue);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
/// <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(double.NaN, 0, 0, 0, 1.0, 0, 1.0, 50.0, 0);
|
||||
_ps = _s;
|
||||
_priceBuffer.Clear();
|
||||
Last = default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates Relative Volatility Index for a price series (static).
|
||||
/// </summary>
|
||||
/// <param name="source">The source price series.</param>
|
||||
/// <param name="stdevLength">The lookback period for standard deviation.</param>
|
||||
/// <param name="rmaLength">The lookback period for RMA smoothing.</param>
|
||||
/// <returns>A TSeries containing the RVI values.</returns>
|
||||
public static TSeries Calculate(TSeries source, int stdevLength = 10, int rmaLength = 14)
|
||||
{
|
||||
if (stdevLength < 2)
|
||||
{
|
||||
throw new ArgumentException("Standard deviation length must be at least 2", nameof(stdevLength));
|
||||
}
|
||||
if (rmaLength < 1)
|
||||
{
|
||||
throw new ArgumentException("RMA length must be at least 1", nameof(rmaLength));
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Batch(source.Values, vSpan, stdevLength, rmaLength);
|
||||
source.Times.CopyTo(tSpan);
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates RVI for a bar series (static).
|
||||
/// </summary>
|
||||
public static TSeries Calculate(TBarSeries source, int stdevLength = 10, int rmaLength = 14)
|
||||
{
|
||||
var rvi = new Rvi(stdevLength, rmaLength);
|
||||
return rvi.Update(source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch calculation using spans.
|
||||
/// </summary>
|
||||
/// <param name="prices">Price values.</param>
|
||||
/// <param name="output">Output RVI values.</param>
|
||||
/// <param name="stdevLength">The lookback period for standard deviation.</param>
|
||||
/// <param name="rmaLength">The lookback period for RMA smoothing.</param>
|
||||
public static void Batch(
|
||||
ReadOnlySpan<double> prices,
|
||||
Span<double> output,
|
||||
int stdevLength = 10,
|
||||
int rmaLength = 14)
|
||||
{
|
||||
if (stdevLength < 2)
|
||||
{
|
||||
throw new ArgumentException("Standard deviation length must be at least 2", nameof(stdevLength));
|
||||
}
|
||||
if (rmaLength < 1)
|
||||
{
|
||||
throw new ArgumentException("RMA length must be at least 1", nameof(rmaLength));
|
||||
}
|
||||
if (output.Length < prices.Length)
|
||||
{
|
||||
throw new ArgumentException("Output span must be at least as long as prices span", nameof(output));
|
||||
}
|
||||
|
||||
int len = prices.Length;
|
||||
if (len == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double alpha = 1.0 / rmaLength;
|
||||
|
||||
// Price buffer for stddev
|
||||
Span<double> priceBuffer = stdevLength <= 256 ? stackalloc double[stdevLength] : new double[stdevLength];
|
||||
int head = 0;
|
||||
int count = 0;
|
||||
double sum = 0;
|
||||
double sumSq = 0;
|
||||
double prevPrice = double.NaN;
|
||||
double lastValue = 50.0;
|
||||
|
||||
// RMA state
|
||||
double rawRmaUp = 0;
|
||||
double eUp = 1.0;
|
||||
double rawRmaDown = 0;
|
||||
double eDown = 1.0;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
double price = prices[i];
|
||||
|
||||
// First price
|
||||
if (double.IsNaN(prevPrice))
|
||||
{
|
||||
// Handle invalid first price - output neutral and continue
|
||||
if (!double.IsFinite(price))
|
||||
{
|
||||
output[i] = lastValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add to buffer
|
||||
if (count < stdevLength)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
else
|
||||
{
|
||||
double oldest = priceBuffer[head];
|
||||
sum -= oldest;
|
||||
sumSq -= oldest * oldest;
|
||||
}
|
||||
priceBuffer[head] = price;
|
||||
head = (head + 1) % stdevLength;
|
||||
sum += price;
|
||||
sumSq += price * price;
|
||||
|
||||
prevPrice = price;
|
||||
output[i] = 50.0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle invalid price
|
||||
if (!double.IsFinite(price))
|
||||
{
|
||||
output[i] = lastValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Price change direction
|
||||
double priceChange = price - prevPrice;
|
||||
prevPrice = price;
|
||||
|
||||
// Update buffer
|
||||
if (count < stdevLength)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
else
|
||||
{
|
||||
double oldest = priceBuffer[head];
|
||||
sum -= oldest;
|
||||
sumSq -= oldest * oldest;
|
||||
}
|
||||
priceBuffer[head] = price;
|
||||
head = (head + 1) % stdevLength;
|
||||
sum += price;
|
||||
sumSq += price * price;
|
||||
|
||||
// Population stddev
|
||||
double currentStdDev = 0.0;
|
||||
if (count > 1)
|
||||
{
|
||||
double mean = sum / count;
|
||||
double variance = (sumSq / count) - (mean * mean);
|
||||
variance = Math.Max(0.0, variance);
|
||||
currentStdDev = Math.Sqrt(variance);
|
||||
}
|
||||
|
||||
// Classify by direction
|
||||
double upStdVal = 0.0;
|
||||
double downStdVal = 0.0;
|
||||
if (priceChange > 0)
|
||||
{
|
||||
upStdVal = currentStdDev;
|
||||
}
|
||||
else if (priceChange < 0)
|
||||
{
|
||||
downStdVal = currentStdDev;
|
||||
}
|
||||
|
||||
// RMA with bias correction
|
||||
rawRmaUp = Math.FusedMultiplyAdd(rawRmaUp, rmaLength - 1, upStdVal) / rmaLength;
|
||||
eUp = (1 - alpha) * eUp;
|
||||
double avgUpStd = eUp > Epsilon ? rawRmaUp / (1.0 - eUp) : rawRmaUp;
|
||||
|
||||
rawRmaDown = Math.FusedMultiplyAdd(rawRmaDown, rmaLength - 1, downStdVal) / rmaLength;
|
||||
eDown = (1 - alpha) * eDown;
|
||||
double avgDownStd = eDown > Epsilon ? rawRmaDown / (1.0 - eDown) : rawRmaDown;
|
||||
|
||||
// RVI
|
||||
double sumAvgStd = avgUpStd + avgDownStd;
|
||||
double rviValue = sumAvgStd > Epsilon ? (100.0 * avgUpStd / sumAvgStd) : 50.0;
|
||||
|
||||
if (!double.IsFinite(rviValue))
|
||||
{
|
||||
rviValue = lastValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
lastValue = rviValue;
|
||||
}
|
||||
|
||||
output[i] = rviValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
# RVI: Relative Volatility Index
|
||||
|
||||
> "Not all volatility is created equal—upward volatility feels like profit, downward volatility feels like loss. RVI separates these psychological experiences into a quantifiable measure."
|
||||
|
||||
The Relative Volatility Index (RVI) is a directional volatility oscillator that distinguishes between upward and downward price volatility. Originally developed by Donald Dorsey in 1993, RVI measures the standard deviation of closing prices and categorizes this volatility based on whether prices are rising or falling. The result is an oscillator bounded between 0 and 100, where values above 50 indicate upward volatility dominance and values below 50 indicate downward volatility dominance.
|
||||
|
||||
## Historical Context
|
||||
|
||||
Donald Dorsey introduced the Relative Volatility Index in the June 1993 issue of *Technical Analysis of Stocks & Commodities* magazine. Dorsey designed RVI as a confirmation indicator rather than a standalone signal generator, intending it to be used alongside RSI to confirm trend strength and momentum.
|
||||
|
||||
The key innovation was separating volatility into directional components. Traditional volatility measures (standard deviation, ATR) treat upward and downward price movements identically. Dorsey recognized that traders experience these movements differently: upward volatility in a long position feels like opportunity, while downward volatility feels like risk.
|
||||
|
||||
The original 1993 formula used a 10-period standard deviation and 14-period Wilder's smoothing (RMA). This implementation follows the PineScript reference which uses bias-corrected RMA to ensure proper warmup behavior during the initial periods.
|
||||
|
||||
## Architecture & Physics
|
||||
|
||||
### 1. Rolling Population Standard Deviation
|
||||
|
||||
First, compute the population standard deviation of closing prices over `stdevLength` periods:
|
||||
|
||||
$$
|
||||
\sigma_t = \sqrt{\frac{\sum_{i=0}^{n-1}(P_{t-i} - \bar{P})^2}{n}}
|
||||
$$
|
||||
|
||||
where:
|
||||
|
||||
- $P_t$ = Closing price at time $t$
|
||||
- $\bar{P}$ = Mean of prices in the window
|
||||
- $n$ = `stdevLength` (default 10)
|
||||
|
||||
The computational form uses running sums for O(1) updates:
|
||||
|
||||
$$
|
||||
\sigma_t = \sqrt{\frac{\sum P_i^2}{n} - \left(\frac{\sum P_i}{n}\right)^2}
|
||||
$$
|
||||
|
||||
### 2. Directional Classification
|
||||
|
||||
Based on price change direction, assign the volatility to either upward or downward:
|
||||
|
||||
$$
|
||||
\text{upStd}_t = \begin{cases}
|
||||
\sigma_t & \text{if } P_t > P_{t-1} \\
|
||||
0 & \text{otherwise}
|
||||
\end{cases}
|
||||
$$
|
||||
|
||||
$$
|
||||
\text{downStd}_t = \begin{cases}
|
||||
\sigma_t & \text{if } P_t < P_{t-1} \\
|
||||
0 & \text{otherwise}
|
||||
\end{cases}
|
||||
$$
|
||||
|
||||
Note: When $P_t = P_{t-1}$ (unchanged), both upStd and downStd are zero. The volatility is "orphaned" rather than assigned to either direction.
|
||||
|
||||
### 3. Bias-Corrected RMA Smoothing
|
||||
|
||||
Both directional volatilities are smoothed using Wilder's RMA (Exponential Moving Average with $\alpha = 1/n$) with bias correction for proper warmup:
|
||||
|
||||
**Raw RMA update:**
|
||||
|
||||
$$
|
||||
\text{raw}_t = \frac{\text{raw}_{t-1} \cdot (n-1) + x_t}{n}
|
||||
$$
|
||||
|
||||
**Bias correction factor:**
|
||||
|
||||
$$
|
||||
e_t = (1 - \alpha) \cdot e_{t-1}
|
||||
$$
|
||||
|
||||
**Corrected output:**
|
||||
|
||||
$$
|
||||
\text{avgStd}_t = \begin{cases}
|
||||
\frac{\text{raw}_t}{1 - e_t} & \text{if } e_t > \epsilon \\
|
||||
\text{raw}_t & \text{otherwise}
|
||||
\end{cases}
|
||||
$$
|
||||
|
||||
where $\alpha = 1/\text{rmaLength}$ and $\epsilon = 10^{-10}$.
|
||||
|
||||
This bias correction compensates for the zero initialization of raw RMA, preventing artificially low values during warmup.
|
||||
|
||||
### 4. Final RVI Calculation
|
||||
|
||||
$$
|
||||
\text{RVI}_t = \begin{cases}
|
||||
100 \times \frac{\text{avgUpStd}_t}{\text{avgUpStd}_t + \text{avgDownStd}_t} & \text{if sum} > 0 \\
|
||||
50 & \text{otherwise}
|
||||
\end{cases}
|
||||
$$
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
### Relationship to RSI
|
||||
|
||||
RVI shares structural similarity with RSI, but measures different quantities:
|
||||
|
||||
| Aspect | RSI | RVI |
|
||||
| :--- | :--- | :--- |
|
||||
| **Measures** | Price changes | Price volatility |
|
||||
| **Up component** | Positive price change | Stddev when price rises |
|
||||
| **Down component** | Negative price change | Stddev when price falls |
|
||||
| **Formula** | $100 \times \frac{\text{avgGain}}{\text{avgGain} + \text{avgLoss}}$ | $100 \times \frac{\text{avgUpStd}}{\text{avgUpStd} + \text{avgDownStd}}$ |
|
||||
| **Range** | 0-100 | 0-100 |
|
||||
|
||||
Both use RMA smoothing for the components.
|
||||
|
||||
### Bias Correction Derivation
|
||||
|
||||
Standard RMA initialized to zero produces biased estimates during warmup. After $t$ updates:
|
||||
|
||||
$$
|
||||
\text{bias} = (1 - \alpha)^t
|
||||
$$
|
||||
|
||||
The correction factor $1/(1-e_t)$ cancels this bias, ensuring unbiased estimates from the first bar.
|
||||
|
||||
### Interpretation Zones
|
||||
|
||||
| RVI Range | Interpretation |
|
||||
| :---: | :--- |
|
||||
| 80-100 | Strong upward volatility dominance |
|
||||
| 60-80 | Moderate upward volatility |
|
||||
| 40-60 | Neutral/balanced volatility |
|
||||
| 20-40 | Moderate downward volatility |
|
||||
| 0-20 | Strong downward volatility dominance |
|
||||
|
||||
### Warmup Period
|
||||
|
||||
RVI requires `stdevLength` bars for the standard deviation calculation plus additional bars for RMA convergence. Effective warmup:
|
||||
|
||||
$$
|
||||
\text{warmup} \approx \text{stdevLength} + 3 \times \text{rmaLength}
|
||||
$$
|
||||
|
||||
With defaults (10, 14): approximately 52 bars for 95% convergence.
|
||||
|
||||
## Performance Profile
|
||||
|
||||
### Operation Count (Streaming Mode, Scalar)
|
||||
|
||||
Per-bar operations after warmup:
|
||||
|
||||
| Operation | Count | Cost (cycles) | Subtotal |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| ADD/SUB | 8 | 1 | 8 |
|
||||
| MUL | 6 | 3 | 18 |
|
||||
| DIV | 5 | 15 | 75 |
|
||||
| SQRT | 1 | 15 | 15 |
|
||||
| CMP | 3 | 1 | 3 |
|
||||
| **Total** | — | — | **~119 cycles** |
|
||||
|
||||
Dominant cost: five divisions (63%) for variance calculation and RMA updates.
|
||||
|
||||
### Memory Profile
|
||||
|
||||
- **State struct:** ~88 bytes (stddev buffer, RMA states, counters)
|
||||
- **RingBuffer:** 8 bytes × stdevLength (default 80 bytes)
|
||||
- **100 instances @ defaults:** ~16.8 KB
|
||||
|
||||
### Quality Metrics
|
||||
|
||||
| Metric | Score | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| **Accuracy** | 8/10 | Precise directional volatility measurement |
|
||||
| **Timeliness** | 7/10 | RMA smoothing introduces lag |
|
||||
| **Responsiveness** | 7/10 | Responds to volatility regime changes |
|
||||
| **Smoothness** | 8/10 | Double smoothing (stddev + RMA) |
|
||||
| **Interpretability** | 9/10 | Clear 0-100 scale with intuitive meaning |
|
||||
|
||||
## Validation
|
||||
|
||||
| Library | Status | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| **TA-Lib** | N/A | Not implemented |
|
||||
| **Skender** | N/A | Not implemented |
|
||||
| **Tulip** | N/A | Not implemented |
|
||||
| **OoplesFinance** | ❔ | Different algorithm (RSI-based) |
|
||||
| **PineScript** | ✅ | Matches rvi.pine reference |
|
||||
|
||||
Note: Some libraries implement "RVI" as a different indicator (often RSI applied to volatility). This implementation follows Dorsey's original design using directional standard deviation.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Confusion with other RVI indicators**: "RVI" name is used for at least three different indicators. Dorsey's original (this implementation) uses directional standard deviation. Others use RSI-like calculations on price or volume. Verify the algorithm before comparing values.
|
||||
|
||||
2. **Unchanged price handling**: When price doesn't change ($P_t = P_{t-1}$), the volatility is orphaned (neither up nor down). Extended flat periods push RVI toward 50 regardless of prior trend.
|
||||
|
||||
3. **Warmup period**: With defaults, RVI needs ~52 bars to converge. Early values may be misleading. The `IsHot` property indicates warmup completion.
|
||||
|
||||
4. **Not a standalone signal**: Dorsey designed RVI as a confirmation indicator. Use with RSI or trend indicators, not alone. High RVI confirms uptrend strength; low RVI confirms downtrend strength.
|
||||
|
||||
5. **Volatility vs direction confusion**: RVI measures which direction has MORE volatility, not which direction price is moving. A slow steady uptrend with occasional sharp drops can show low RVI despite rising prices.
|
||||
|
||||
6. **Parameter sensitivity**: Shorter stdevLength increases noise sensitivity. Longer rmaLength increases lag. Default 10/14 balances responsiveness and stability.
|
||||
|
||||
7. **Zero denominator**: When both avgUpStd and avgDownStd approach zero (flat market), RVI defaults to 50. This is mathematically correct but may mask the lack of volatility.
|
||||
|
||||
## Trading Applications
|
||||
|
||||
### Trend Confirmation (Dorsey's Original Use)
|
||||
|
||||
Combine with RSI for confirmation:
|
||||
|
||||
```
|
||||
RSI > 50 AND RVI > 50: Confirmed uptrend
|
||||
RSI < 50 AND RVI < 50: Confirmed downtrend
|
||||
RSI > 50 AND RVI < 50: Divergence - uptrend weakening
|
||||
RSI < 50 AND RVI > 50: Divergence - downtrend weakening
|
||||
```
|
||||
|
||||
### Volatility Regime Detection
|
||||
|
||||
Track RVI for directional volatility shifts:
|
||||
|
||||
```
|
||||
RVI crossing above 60: Upward volatility expanding
|
||||
RVI crossing below 40: Downward volatility expanding
|
||||
RVI oscillating 40-60: Balanced/consolidating
|
||||
```
|
||||
|
||||
### Entry/Exit Filters
|
||||
|
||||
Use RVI as a filter for other signals:
|
||||
|
||||
```
|
||||
Only take long entries when RVI > 50 (upward volatility dominance)
|
||||
Only take short entries when RVI < 50 (downward volatility dominance)
|
||||
```
|
||||
|
||||
### Divergence Trading
|
||||
|
||||
RVI divergences from price can signal reversals:
|
||||
|
||||
```
|
||||
Price making higher highs + RVI making lower highs: Bearish divergence
|
||||
Price making lower lows + RVI making higher lows: Bullish divergence
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- Dorsey, D. (1993). "The Relative Volatility Index." *Technical Analysis of Stocks & Commodities*, 11(6), 253-256.
|
||||
- Dorsey, D. (1995). "Refining the Relative Volatility Index." *Technical Analysis of Stocks & Commodities*, 13(9).
|
||||
- TradingView. (2024). "PineScript Reference Implementation." rvi.pine source file.
|
||||
Reference in New Issue
Block a user