Files
Miha Kralj 060649192f 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
2026-03-12 12:34:16 -07:00

625 lines
19 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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));
}
}