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:
Miha Kralj
2026-03-12 12:34:16 -07:00
parent 8937b0c0fa
commit 060649192f
1149 changed files with 1780 additions and 3316 deletions
@@ -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}");
}
}
+694
View File
@@ -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));
}
}