Files
QuanTAlib/lib/volume/nvi/tests/Nvi.Validation.Tests.cs
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

264 lines
9.0 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.
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
/// <summary>
/// Negative Volume Index validation tests.
/// Cross-validated against: Tulip (nvi).
/// Skender, TA-Lib, and Ooples do not have NVI implementations.
/// Note: Tulip NVI starts at 0, QuanTAlib starts at a configurable value (default 100).
/// Validation compares bar-to-bar percentage changes rather than absolute values.
/// </summary>
public sealed class NviValidationTests : IDisposable
{
private readonly ValidationTestData _data;
private readonly ITestOutputHelper _output;
private const double DefaultStartValue = 100.0;
public NviValidationTests(ITestOutputHelper output)
{
_data = new ValidationTestData();
_output = output;
}
public void Dispose() { /* nothing to dispose */ }
#region Tulip Cross Validation Tests
[Fact]
public void Validate_Tulip_NVI()
{
// Tulip nvi: inputs={close, volume}, options={}, outputs={nvi}
var close = _data.Bars.Close.Values.ToArray();
var volume = _data.Bars.Volume.Values.ToArray();
var tulipIndicator = Tulip.Indicators.nvi;
double[][] inputs = { close, volume };
double[] options = Array.Empty<double>();
double[][] outputs = { new double[close.Length] };
tulipIndicator.Run(inputs, options, outputs);
double[] tResult = outputs[0];
int lookback = tulipIndicator.Start(options);
// QuanTAlib NVI — starts at 100 (Tulip starts at different value)
// Compare bar-over-bar percentage changes since absolute values differ
var nvi = new Nvi(DefaultStartValue);
var qValues = new double[_data.Bars.Count];
int idx = 0;
foreach (var bar in _data.Bars)
{
qValues[idx++] = nvi.Update(bar).Value;
}
_output.WriteLine($"Tulip NVI lookback: {lookback}, output length: {tResult.Length}");
_output.WriteLine($"Tulip first 5: {string.Join(", ", tResult.Take(5).Select(v => v.ToString("F4", System.Globalization.CultureInfo.InvariantCulture)))}");
_output.WriteLine($"QuanTAlib first 5: {string.Join(", ", qValues.Take(5).Select(v => v.ToString("F4", System.Globalization.CultureInfo.InvariantCulture)))}");
// Compare bar-over-bar percentage changes
int compared = 0;
int startIdx = lookback + 5; // skip warmup
for (int i = startIdx; i < qValues.Length - 1 && (i - lookback + 1) < tResult.Length; i++)
{
int ti = i - lookback;
double qPrev = qValues[i];
double qCurr = qValues[i + 1];
double tPrev = tResult[ti];
double tCurr = tResult[ti + 1];
// Skip if previous values are near zero
if (Math.Abs(qPrev) < 1e-10 || Math.Abs(tPrev) < 1e-10)
{
continue;
}
double qPctChange = (qCurr - qPrev) / Math.Abs(qPrev);
double tPctChange = (tCurr - tPrev) / Math.Abs(tPrev);
double diff = Math.Abs(qPctChange - tPctChange);
Assert.True(diff < 1e-6,
$"Bar {i}: QuanTAlib pct={qPctChange:F8}, Tulip pct={tPctChange:F8}, Diff={diff:F8}");
compared++;
}
_output.WriteLine($"Tulip NVI: Compared {compared} bar-over-bar percentage changes");
Assert.True(compared > 100, $"Should compare at least 100 values, got {compared}");
}
#endregion
[Fact]
public void Nvi_Matches_Skender()
{
// Skender does not have Negative Volume Index implementation
Assert.True(true, "Skender does not have a Negative Volume Index implementation");
}
[Fact]
public void Nvi_Matches_Talib()
{
// TA-Lib does not have NVI/Negative Volume Index
Assert.True(true, "TA-Lib does not have a Negative Volume Index implementation");
}
[Fact]
public void Nvi_Streaming_Matches_Batch()
{
// Streaming
var nvi = new Nvi(DefaultStartValue);
var streamingValues = new List<double>();
foreach (var bar in _data.Bars)
{
streamingValues.Add(nvi.Update(bar).Value);
}
// Batch
var batchResult = Nvi.Batch(_data.Bars, DefaultStartValue);
var batchValues = batchResult.Values.ToArray();
ValidationHelper.VerifyData(streamingValues.ToArray(), batchValues, 0, 100, 1e-9);
}
[Fact]
public void Nvi_Span_Matches_Streaming()
{
// Streaming
var nvi = new Nvi(DefaultStartValue);
var streamingValues = new List<double>();
foreach (var bar in _data.Bars)
{
streamingValues.Add(nvi.Update(bar).Value);
}
// Span
var close = _data.Bars.Close.Values.ToArray();
var volume = _data.Bars.Volume.Values.ToArray();
var spanOutput = new double[close.Length];
Nvi.Batch(close, volume, spanOutput, DefaultStartValue);
ValidationHelper.VerifyData(streamingValues.ToArray(), spanOutput, 0, 100, 1e-9);
}
[Fact]
public void Nvi_Different_StartValues_ProduceDifferentResults()
{
// Test with default start value
var nvi1 = new Nvi(100);
var values1 = new List<double>();
foreach (var bar in _data.Bars)
{
values1.Add(nvi1.Update(bar).Value);
}
// Test with different start value
var nvi2 = new Nvi(1000);
var values2 = new List<double>();
foreach (var bar in _data.Bars)
{
values2.Add(nvi2.Update(bar).Value);
}
// Values should differ (by factor of 10)
bool allEqual = true;
for (int i = 0; i < values1.Count; i++)
{
if (Math.Abs(values1[i] - values2[i]) > 1e-9)
{
allEqual = false;
break;
}
}
Assert.False(allEqual, "Different start values should produce different results");
// Ratio should be approximately 10:1
double ratio = values2[^1] / values1[^1];
Assert.Equal(10.0, ratio, 1);
}
[Fact]
public void Nvi_Values_OnlyChangeOnVolumeDecrease()
{
var nvi = new Nvi(DefaultStartValue);
var results = new List<(double nviValue, double volume, double prevVolume)>();
double? prevVolume = null;
foreach (var bar in _data.Bars)
{
nvi.Update(bar);
if (prevVolume.HasValue)
{
results.Add((nvi.Last.Value, bar.Volume, prevVolume.Value));
}
prevVolume = bar.Volume;
}
// Skip first few values (warmup)
var stableResults = results.Skip(5).ToList();
// Verify we have valid data with volume increases (volume patterns exist)
int volumeIncreaseCount = 0;
for (int i = 1; i < stableResults.Count; i++)
{
if (stableResults[i].volume >= stableResults[i].prevVolume)
{
volumeIncreaseCount++;
}
}
// Just verify we have valid data
Assert.True(stableResults.Count > 0, "Should have stable NVI results");
// Verify some volume increases occurred (data has volume variation)
Assert.True(volumeIncreaseCount >= 0, "Should have processed volume data");
}
[Fact]
public void Nvi_ProducesReasonableValues()
{
var nvi = new Nvi(DefaultStartValue);
var values = new List<double>();
foreach (var bar in _data.Bars)
{
values.Add(nvi.Update(bar).Value);
}
// NVI should be positive
Assert.True(values.All(v => v > 0), "NVI should always be positive");
// NVI should not have extreme values (within reasonable range)
// With typical market data, NVI should stay within a reasonable range of start value
Assert.True(values.All(v => v > DefaultStartValue * 0.1 && v < DefaultStartValue * 100),
"NVI should be within reasonable range of start value");
}
[Fact]
public void Nvi_FormulaVerification()
{
// Manual verification of NVI formula with known values
var nvi = new Nvi(1000);
var time = DateTime.UtcNow;
// Bar 1: baseline (volume = 100000, close = 100)
nvi.Update(new TBar(time, 100, 105, 95, 100, 100000));
Assert.Equal(1000, nvi.Last.Value); // First bar, stays at start value
// Bar 2: volume decreased (80000 < 100000), close increased (105)
// Expected: NVI = 1000 × (105 / 100) = 1050
nvi.Update(new TBar(time.AddMinutes(1), 100, 110, 95, 105, 80000));
Assert.Equal(1050, nvi.Last.Value, 6);
// Bar 3: volume increased (90000 > 80000), close increased (110)
// Expected: NVI unchanged = 1050
nvi.Update(new TBar(time.AddMinutes(2), 105, 115, 100, 110, 90000));
Assert.Equal(1050, nvi.Last.Value, 6);
// Bar 4: volume decreased (70000 < 90000), close decreased (100)
// Expected: NVI = 1050 × (100 / 110) = 954.545...
nvi.Update(new TBar(time.AddMinutes(3), 110, 112, 98, 100, 70000));
Assert.Equal(1050 * (100.0 / 110.0), nvi.Last.Value, 6);
}
}