2026-02-26 22:02:52 -08:00
|
|
|
|
// OoplesFinance does not have a Relative Volatility Index (RVI) implementation.
|
|
|
|
|
|
// CalculateRelativeVolatility is not present in OoplesFinance.StockIndicators v1.1.1.
|
|
|
|
|
|
|
2026-02-02 13:42:47 -08:00
|
|
|
|
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++)
|
|
|
|
|
|
{
|
2026-03-11 03:35:12 +00:00
|
|
|
|
rvi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + (i * 0.5)));
|
2026-02-02 13:42:47 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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++)
|
|
|
|
|
|
{
|
2026-03-11 03:35:12 +00:00
|
|
|
|
rvi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 - (i * 0.5)));
|
2026-02-02 13:42:47 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-02-10 21:33:16 -08:00
|
|
|
|
var batchResult = Rvi.Batch(prices, 10, 14);
|
2026-02-02 13:42:47 -08:00
|
|
|
|
|
|
|
|
|
|
// 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>
|
fix(docs): correct .md documentation across errors, dynamics, filters, forecasts, momentum, numerics, oscillators, reversals, statistics, trends, volatility, volume
2026-03-10 18:38:23 -07:00
|
|
|
|
/// Validates TBar update uses High and Low channels (revised 1995 algorithm),
|
|
|
|
|
|
/// producing a different result than single-price Close-only input.
|
2026-02-02 13:42:47 -08:00
|
|
|
|
/// </summary>
|
|
|
|
|
|
[Fact]
|
fix(docs): correct .md documentation across errors, dynamics, filters, forecasts, momentum, numerics, oscillators, reversals, statistics, trends, volatility, volume
2026-03-10 18:38:23 -07:00
|
|
|
|
public void Rvi_TBar_UsesDualChannel_HighLow()
|
2026-02-02 13:42:47 -08:00
|
|
|
|
{
|
|
|
|
|
|
var bars = GenerateTestData(50);
|
|
|
|
|
|
|
fix(docs): correct .md documentation across errors, dynamics, filters, forecasts, momentum, numerics, oscillators, reversals, statistics, trends, volatility, volume
2026-03-10 18:38:23 -07:00
|
|
|
|
// Using TBar (revised: high + low dual-channel)
|
2026-02-02 13:42:47 -08:00
|
|
|
|
var rviBar = new Rvi(10, 14);
|
|
|
|
|
|
for (int i = 0; i < bars.Count; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
rviBar.Update(bars[i]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(docs): correct .md documentation across errors, dynamics, filters, forecasts, momentum, numerics, oscillators, reversals, statistics, trends, volatility, volume
2026-03-10 18:38:23 -07:00
|
|
|
|
// Using just Close prices (single-channel)
|
2026-02-02 13:42:47 -08:00
|
|
|
|
var rviClose = new Rvi(10, 14);
|
|
|
|
|
|
for (int i = 0; i < bars.Count; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
rviClose.Update(new TValue(bars[i].Time, bars[i].Close));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(docs): correct .md documentation across errors, dynamics, filters, forecasts, momentum, numerics, oscillators, reversals, statistics, trends, volatility, volume
2026-03-10 18:38:23 -07:00
|
|
|
|
// 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);
|
2026-02-02 13:42:47 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// === 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++)
|
|
|
|
|
|
{
|
2026-03-11 03:35:12 +00:00
|
|
|
|
var tempPrice = new TValue(prices[29].Time, prices[29].Value * (1.0 + (j * 0.01)));
|
2026-02-02 13:42:47 -08:00
|
|
|
|
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++)
|
|
|
|
|
|
{
|
2026-03-11 03:35:12 +00:00
|
|
|
|
double price = 100.0 + (Math.Sin(i * 0.1) * 5); // Oscillating ±5
|
2026-02-02 13:42:47 -08:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
2026-02-27 12:50:05 -08:00
|
|
|
|
}
|