mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-23 04:58:08 +00:00
docs: remove C# Implementation Considerations sections, clean up temp scripts, reorganize test files
- Remove 'C# Implementation Considerations' sections from 34 indicator .md files - Delete 29 temp PowerShell scripts (_fix_mojibake.ps1, _hex_scan.ps1, etc.) - Move test files into tests/ subdirectories for consistent project structure - Add trader-focused bullet points to indicator documentation
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,694 @@
|
||||
// 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_UsesHighAndLow()
|
||||
{
|
||||
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 is always neutral
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_WithTBar_RevisedDiffersFromOriginal()
|
||||
{
|
||||
// The revised RVI (high+low avg) should differ from original (close-only)
|
||||
// Use oscillating close with asymmetric high/low
|
||||
var rviBar = new Rvi(stdevLength: 5, rmaLength: 5);
|
||||
var rviClose = new Rvi(stdevLength: 5, rmaLength: 5);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var time = DateTime.UtcNow.AddSeconds(i);
|
||||
double close = 100.0 + (Math.Sin(i * 0.5) * 3.0); // oscillating
|
||||
double high = close + 2.0 + (Math.Sin(i * 0.3) * 1.5); // asymmetric highs
|
||||
double low = close - 1.0 - (Math.Cos(i * 0.7) * 0.8); // asymmetric lows
|
||||
|
||||
rviBar.Update(new TBar(time, close - 0.5, high, low, close, 1000));
|
||||
rviClose.Update(new TValue(time, close));
|
||||
}
|
||||
|
||||
// With asymmetric high/low, revised RVI should differ from close-only
|
||||
Assert.NotEqual(rviBar.Last.Value, rviClose.Last.Value, 0.01);
|
||||
}
|
||||
|
||||
[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.Batch(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;
|
||||
var bars = new GBM(seed: 42).Fetch(dataLen, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
double[] prices = bars.CloseValues.ToArray();
|
||||
double[] output = new double[dataLen];
|
||||
|
||||
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.Batch(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.Batch(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,624 @@
|
||||
// OoplesFinance does not have a Relative Volatility Index (RVI) implementation.
|
||||
// CalculateRelativeVolatility is not present in OoplesFinance.StockIndicators v1.1.1.
|
||||
|
||||
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.Batch(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 High and Low channels (revised 1995 algorithm),
|
||||
/// producing a different result than single-price Close-only input.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Rvi_TBar_UsesDualChannel_HighLow()
|
||||
{
|
||||
var bars = GenerateTestData(50);
|
||||
|
||||
// Using TBar (revised: high + low dual-channel)
|
||||
var rviBar = new Rvi(10, 14);
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
rviBar.Update(bars[i]);
|
||||
}
|
||||
|
||||
// Using just Close prices (single-channel)
|
||||
var rviClose = new Rvi(10, 14);
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
rviClose.Update(new TValue(bars[i].Time, bars[i].Close));
|
||||
}
|
||||
|
||||
// TBar uses High/Low channels → different from Close-only
|
||||
Assert.NotEqual(rviClose.Last.Value, rviBar.Last.Value);
|
||||
|
||||
// Both should still be in valid range
|
||||
Assert.True(rviBar.Last.Value >= 0 && rviBar.Last.Value <= 100);
|
||||
Assert.True(rviClose.Last.Value >= 0 && rviClose.Last.Value <= 100);
|
||||
}
|
||||
|
||||
// === 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user