Files
Miha Kralj 6f0a339c9b fix: resolve build and test errors
- Sar.Quantower.Tests.cs: add missing opening quote on string literal (line 48)
- Exports.cs: rename Correlation.Batch → Correl.Batch (CS0103)
- Ad.Validation.Tests.cs: fix Ooples OutputValues key "Ad" → "Adl"
2026-03-16 12:45:13 -07:00

579 lines
16 KiB
C#

using Skender.Stock.Indicators;
using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for RS (Price Relative Strength) indicator.
/// RS compares relative performance between two assets via their price ratio.
/// Note: RS is a unique indicator without direct equivalents in TA-Lib, Skender, etc.
/// These tests validate mathematical correctness and expected behavior.
/// </summary>
public class RsValidationTests
{
#region Mathematical Validation
[Fact]
public void Rs_ManualCalculation_MatchesExpected()
{
var rs = new Rs(1); // No smoothing
var time = DateTime.UtcNow;
// Test data: base and comparison prices
var baseValues = new double[] { 100, 105, 110, 115, 120 };
var compValues = new double[] { 100, 100, 100, 100, 100 };
// Expected: ratios should be 1.0, 1.05, 1.10, 1.15, 1.20
for (int i = 0; i < baseValues.Length; i++)
{
var result = rs.Update(
new TValue(time.AddSeconds(i), baseValues[i]),
new TValue(time.AddSeconds(i), compValues[i]),
true);
double expected = baseValues[i] / compValues[i];
Assert.Equal(expected, result.Value, 10);
Assert.Equal(expected, rs.RawRatio, 10);
}
}
[Fact]
public void Rs_EqualPrices_ReturnsOne()
{
var rs = new Rs(1);
var result = rs.Update(50.0, 50.0, true);
Assert.Equal(1.0, result.Value, 10);
}
[Fact]
public void Rs_BaseHigherThanComp_ReturnsGreaterThanOne()
{
var rs = new Rs(1);
var result = rs.Update(120.0, 100.0, true);
Assert.Equal(1.2, result.Value, 10);
Assert.True(result.Value > 1.0);
}
[Fact]
public void Rs_BaseLowerThanComp_ReturnsLessThanOne()
{
var rs = new Rs(1);
var result = rs.Update(80.0, 100.0, true);
Assert.Equal(0.8, result.Value, 10);
Assert.True(result.Value < 1.0);
}
[Fact]
public void Rs_DivisionByZero_ReturnsNaN()
{
var rs = new Rs(1);
var result = rs.Update(100.0, 0.0, true);
Assert.True(double.IsNaN(result.Value));
Assert.True(double.IsNaN(rs.RawRatio));
}
[Fact]
public void Rs_VerySmallDenominator_ReturnsNaN()
{
var rs = new Rs(1);
// Value smaller than epsilon (1e-10) should be treated as zero
var result = rs.Update(100.0, 1e-11, true);
Assert.True(double.IsNaN(result.Value));
}
#endregion
#region Smoothing Validation
[Fact]
public void Rs_SmoothedFirstValue_EqualsRawRatio()
{
var rs = new Rs(10);
var time = DateTime.UtcNow;
var result = rs.Update(
new TValue(time, 100.0),
new TValue(time, 50.0),
true);
// First value should equal raw ratio
Assert.Equal(rs.RawRatio, result.Value, 10);
Assert.Equal(2.0, result.Value, 10);
}
[Fact]
public void Rs_SmoothedConvergesToRatio_WhenConstant()
{
var rs = new Rs(5);
var time = DateTime.UtcNow;
// Feed constant ratio (100/50 = 2.0) repeatedly
TValue result = default;
for (int i = 0; i < 50; i++)
{
result = rs.Update(
new TValue(time.AddSeconds(i), 100.0),
new TValue(time.AddSeconds(i), 50.0),
true);
}
// Should converge to 2.0
Assert.Equal(2.0, result.Value, 6);
}
[Fact]
public void Rs_NoSmoothing_RawRatioEqualsSmoothed()
{
var rs = new Rs(1); // No smoothing
var values = new (double b, double c)[]
{
(100, 50),
(110, 55),
(120, 60),
(130, 65)
};
foreach (var (b, c) in values)
{
var result = rs.Update(b, c, true);
Assert.Equal(rs.RawRatio, result.Value, 10);
}
}
[Fact]
public void Rs_SmoothingReducesVolatility()
{
var prsNoSmooth = new Rs(1);
var prsSmooth = new Rs(10);
// Create volatile ratio series
var baseVals = new double[] { 100, 120, 80, 130, 70, 140, 60, 150 };
var compVals = new double[] { 100, 100, 100, 100, 100, 100, 100, 100 };
var rawResults = new List<double>();
var smoothResults = new List<double>();
for (int i = 0; i < baseVals.Length; i++)
{
var raw = prsNoSmooth.Update(baseVals[i], compVals[i], true);
var smooth = prsSmooth.Update(baseVals[i], compVals[i], true);
rawResults.Add(raw.Value);
smoothResults.Add(smooth.Value);
}
// Calculate variance of latter half
var rawVariance = CalculateVariance(rawResults.Skip(4).ToArray());
var smoothVariance = CalculateVariance(smoothResults.Skip(4).ToArray());
Assert.True(smoothVariance <= rawVariance + 0.01,
$"Smoothed variance ({smoothVariance:F4}) should be <= raw variance ({rawVariance:F4})");
}
private static double CalculateVariance(double[] values)
{
double mean = values.Average();
return values.Select(v => (v - mean) * (v - mean)).Sum() / values.Length;
}
#endregion
#region Trend Interpretation
[Fact]
public void Rs_IncreasingRatio_IndicatesOutperformance()
{
var rs = new Rs(1);
// Base outperforms: grows faster than comparison
var results = new List<double>();
for (int i = 0; i < 10; i++)
{
double basePrice = 100 + (i * 5); // 100, 105, 110...
double compPrice = 100 + (i * 2); // 100, 102, 104...
var result = rs.Update(basePrice, compPrice, true);
results.Add(result.Value);
}
// Each ratio should be larger than the previous (outperformance)
for (int i = 1; i < results.Count; i++)
{
Assert.True(results[i] > results[i - 1],
$"Outperformance: ratio[{i}]={results[i]:F4} should be > ratio[{i - 1}]={results[i - 1]:F4}");
}
}
[Fact]
public void Rs_DecreasingRatio_IndicatesUnderperformance()
{
var rs = new Rs(1);
// Base underperforms: grows slower than comparison
var results = new List<double>();
for (int i = 0; i < 10; i++)
{
double basePrice = 100 + (i * 2); // 100, 102, 104...
double compPrice = 100 + (i * 5); // 100, 105, 110...
var result = rs.Update(basePrice, compPrice, true);
results.Add(result.Value);
}
// Each ratio should be smaller than the previous (underperformance)
for (int i = 1; i < results.Count; i++)
{
Assert.True(results[i] < results[i - 1],
$"Underperformance: ratio[{i}]={results[i]:F4} should be < ratio[{i - 1}]={results[i - 1]:F4}");
}
}
[Fact]
public void Rs_SameGrowthRate_ConstantRatio()
{
var rs = new Rs(1);
// Both grow at same rate - ratio stays constant at 2.0
var results = new List<double>();
for (int i = 0; i < 10; i++)
{
double basePrice = 100 * (1 + (i * 0.05)); // 5% growth
double compPrice = 50 * (1 + (i * 0.05)); // 5% growth
var result = rs.Update(basePrice, compPrice, true);
results.Add(result.Value);
}
// All ratios should be 2.0 (within precision)
foreach (var ratio in results)
{
Assert.Equal(2.0, ratio, 10);
}
}
#endregion
#region Edge Cases and Robustness
[Fact]
public void Rs_NegativeValues_HandlesCorrectly()
{
var rs = new Rs(1);
// While unusual, RS should handle negative values mathematically
var result = rs.Update(-100.0, -50.0, true);
Assert.Equal(2.0, result.Value, 10); // -100/-50 = 2.0
}
[Fact]
public void Rs_MixedSigns_HandlesCorrectly()
{
var rs = new Rs(1);
// Base positive, comp negative
var result = rs.Update(100.0, -50.0, true);
Assert.Equal(-2.0, result.Value, 10);
}
[Fact]
public void Rs_VeryLargeValues_MaintainsPrecision()
{
var rs = new Rs(1);
var result = rs.Update(1e15, 1e14, true);
// 1e15 / 1e14 = 10
Assert.Equal(10.0, result.Value, 6);
}
[Fact]
public void Rs_VerySmallValues_MaintainsPrecision()
{
var rs = new Rs(1);
var result = rs.Update(1e-5, 1e-6, true);
// 1e-5 / 1e-6 = 10
Assert.Equal(10.0, result.Value, 6);
}
[Fact]
public void Rs_NaNBase_PropagatesNaN()
{
var rs = new Rs(1);
// Should fallback to last valid or 0, resulting in 0/comp
rs.Update(100.0, 50.0, true); // First valid value
var result = rs.Update(double.NaN, 50.0, true);
// NaN base with valid comparison should use fallback (previous: 100) or 0
// Result will depend on sanitization logic - actual behavior uses last valid
Assert.True(double.IsFinite(result.Value) || double.IsNaN(result.Value));
}
[Fact]
public void Rs_NaNComp_PropagatesNaN()
{
var rs = new Rs(1);
rs.Update(100.0, 50.0, true); // First valid value
var result = rs.Update(100.0, double.NaN, true);
// NaN comp should use fallback (previous: 50) -> 100/50 = 2.0
Assert.Equal(2.0, result.Value, 6);
}
[Fact]
public void Rs_InfinityBase_HandledGracefully()
{
var rs = new Rs(1);
rs.Update(100.0, 50.0, true); // First valid
var result = rs.Update(double.PositiveInfinity, 50.0, true);
// Should fallback to last valid (100/50 = 2.0)
Assert.Equal(2.0, result.Value, 6);
}
#endregion
#region Batch Calculation Validation
[Fact]
public void Rs_BatchCalculate_MatchesStreaming()
{
int smoothPeriod = 5;
var prsStream = new Rs(smoothPeriod);
var baseValues = new double[] { 100, 105, 110, 108, 115, 120, 118, 125, 130, 128 };
var compValues = new double[] { 100, 100, 100, 100, 100, 100, 100, 100, 100, 100 };
// Streaming calculation
var streamResults = new double[baseValues.Length];
for (int i = 0; i < baseValues.Length; i++)
{
streamResults[i] = prsStream.Update(baseValues[i], compValues[i], true).Value;
}
// Batch calculation
var batchOutput = new double[baseValues.Length];
Rs.Batch(baseValues, compValues, batchOutput, smoothPeriod);
// Results should match
for (int i = 0; i < baseValues.Length; i++)
{
Assert.Equal(streamResults[i], batchOutput[i], 10);
}
}
[Fact]
public void Rs_TSeriesCalculate_MatchesStreaming()
{
int smoothPeriod = 3;
var prsStream = new Rs(smoothPeriod);
var time = DateTime.UtcNow;
var baseSeries = new TSeries(10);
var compSeries = new TSeries(10);
var baseValues = new double[] { 100, 110, 105, 115, 120, 125, 118, 130, 128, 135 };
var compValues = new double[] { 100, 102, 101, 103, 104, 105, 103, 106, 105, 107 };
for (int i = 0; i < baseValues.Length; i++)
{
baseSeries.Add(new TValue(time.AddSeconds(i), baseValues[i]));
compSeries.Add(new TValue(time.AddSeconds(i), compValues[i]));
}
// Streaming
var streamResults = new double[baseValues.Length];
for (int i = 0; i < baseValues.Length; i++)
{
streamResults[i] = prsStream.Update(baseValues[i], compValues[i], true).Value;
}
// TSeries batch
var batchResult = Rs.Batch(baseSeries, compSeries, smoothPeriod);
for (int i = 0; i < baseValues.Length; i++)
{
Assert.Equal(streamResults[i], batchResult[i].Value, 10);
}
}
#endregion
#region Properties and State
[Fact]
public void Rs_IsHot_BecomesTrue_AfterSmoothPeriod()
{
var rs = new Rs(5);
for (int i = 0; i < 10; i++)
{
rs.Update(100 + i, 100.0, true);
if (i < 4) // 0-4 = first 5 values
{
Assert.False(rs.IsHot, $"Should not be hot at index {i}");
}
else
{
Assert.True(rs.IsHot, $"Should be hot at index {i}");
}
}
}
[Fact]
public void Rs_Reset_ClearsState()
{
var rs = new Rs(5);
// Add values
for (int i = 0; i < 10; i++)
{
rs.Update(100 + i, 50.0, true);
}
Assert.True(rs.IsHot);
Assert.True(rs.RawRatio > 0);
// Reset
rs.Reset();
Assert.False(rs.IsHot);
Assert.Equal(0.0, rs.RawRatio);
Assert.Equal(default(TValue), rs.Last);
}
[Fact]
public void Rs_Prime_InitializesState()
{
var rs = new Rs(5);
var baseSource = new double[] { 100, 105, 110, 115, 120, 125, 130 };
var compSource = new double[] { 100, 100, 100, 100, 100, 100, 100 };
rs.Prime(baseSource, compSource);
Assert.True(rs.IsHot);
Assert.Equal(1.30, rs.RawRatio, 10);
Assert.Equal(130.0 / 100.0, rs.RawRatio, 10);
}
#endregion
#region Performance Properties
[Fact]
public void Rs_SmoothPeriod_ExposesCorrectValue()
{
var rs = new Rs(14);
Assert.Equal(14, rs.SmoothPeriod);
}
[Fact]
public void Rs_WarmupPeriod_EqualsSmoothPeriod()
{
var rs = new Rs(20);
Assert.Equal(20, rs.WarmupPeriod);
}
[Fact]
public void Rs_Name_IncludesPeriodIfSmoothed()
{
var prsNoSmooth = new Rs(1);
var prsSmooth = new Rs(14);
Assert.Equal("Rs", prsNoSmooth.Name);
Assert.Equal("Rs(14)", prsSmooth.Name);
}
#endregion
#region Skender Cross-Validation
/// <summary>
/// Structural validation against Skender <c>GetPrs</c>.
/// Skender PRS computes price ratio between two quote series.
/// QuanTAlib RS also computes base/comparison ratio with optional smoothing.
/// With period=1 (no smoothing), raw ratios should match exactly.
/// </summary>
[Fact]
public void Validate_Skender_Rs_Streaming()
{
using var evalData = new ValidationTestData();
// Create a second quote series for comparison (different seed)
var baseGbm = new GBM(startPrice: 100, mu: 0.02, sigma: 0.12, seed: 999);
var baseBars = baseGbm.Fetch(evalData.Bars.Count, evalData.Bars[0].Time, TimeSpan.FromMinutes(1));
var baseQuotes = baseBars.Select(b => new Quote
{
Date = new DateTime(b.Time, DateTimeKind.Utc),
Open = (decimal)b.Open,
High = (decimal)b.High,
Low = (decimal)b.Low,
Close = (decimal)b.Close,
Volume = (decimal)b.Volume
}).ToList();
// QuanTAlib RS (streaming, no smoothing)
var rs = new Rs(1);
var qResults = new List<double>();
for (int i = 0; i < evalData.Bars.Count; i++)
{
double evalClose = evalData.Bars[i].Close;
double baseClose = baseBars[i].Close;
qResults.Add(rs.Update(evalClose, baseClose, true).Value);
}
// Skender PRS: quotesEval.GetPrs(quotesBase)
var sResult = evalData.SkenderQuotes.GetPrs(baseQuotes).ToList();
// Cross-validate: raw RS ratio (no smoothing)
ValidationHelper.VerifyData(qResults, sResult, s => s.Prs, tolerance: ValidationHelper.SkenderTolerance);
}
[Fact]
public void Rs_Correction_Recomputes()
{
var ind = new Rs(smoothPeriod: 5);
// Build state well past warmup
for (int i = 0; i < 50; i++)
{
ind.Update(100.0 + (i * 0.5), 98.0 + (i * 0.5));
}
// Anchor bar
const double anchorBase = 125.0;
const double anchorComp = 100.0;
ind.Update(anchorBase, anchorComp, isNew: true);
double anchorResult = ind.Last.Value;
// Correction: change base dramatically — ratio changes from 1.25 to 12.5
ind.Update(anchorBase * 10, anchorComp, isNew: false);
Assert.NotEqual(anchorResult, ind.Last.Value);
// Correction back to original — must exactly restore
ind.Update(anchorBase, anchorComp, isNew: false);
Assert.Equal(anchorResult, ind.Last.Value, 1e-9);
}
#endregion
}