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
20 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: CalculateChandeVolatilityIndexDynamicAverageIndicator exists but implements
// a different algorithm (Chande Volatility Index Dynamic Average / VIDA) rather than the
// Chaikin Volatility Index (EMA of High-Low range, then ROC). The two share the "CVI"
// abbreviation but are mathematically distinct. Numeric equality is not expected.
using OoplesFinance.StockIndicators;
using OoplesFinance.StockIndicators.Models;
using Tulip;
namespace QuanTAlib.Test;
using QuanTAlib.Tests;
using Xunit;
/// <summary>
/// Validation tests for CVI (Chaikin's Volatility).
/// CVI measures the rate of change of EMA-smoothed high-low range.
/// Formula: CVI = ((EMA_t - EMA_{t-rocLength}) / EMA_{t-rocLength}) × 100
/// where EMA is applied to (High - Low) range.
/// </summary>
public class CviValidationTests
{
private static TBarSeries GenerateTestData(int count = 100)
{
var gbm = new GBM(seed: 42);
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
}
// === Mathematical Validation ===
/// <summary>
/// Validates the EMA alpha formula: α = 2 / (smoothLength + 1)
/// </summary>
[Theory]
[InlineData(10, 0.181818181818182)] // 2/(10+1) = 0.1818...
[InlineData(14, 0.133333333333333)] // 2/(14+1) = 0.1333...
[InlineData(20, 0.095238095238095)] // 2/(20+1) = 0.0952...
public void Cvi_EmaAlpha_IsCorrect(int smoothLength, double expectedAlpha)
{
double alpha = 2.0 / (smoothLength + 1);
Assert.Equal(expectedAlpha, alpha, 10);
}
/// <summary>
/// Validates ROC formula: ((current - prior) / prior) × 100
/// </summary>
[Fact]
public void Cvi_RocFormula_IsCorrect()
{
// Manual ROC calculation
double currentEma = 10.0;
double priorEma = 8.0;
double expectedRoc = ((currentEma - priorEma) / priorEma) * 100.0;
Assert.Equal(25.0, expectedRoc, 10); // (10-8)/8 * 100 = 25%
}
/// <summary>
/// Validates that constant high-low range produces zero CVI after warmup.
/// </summary>
[Fact]
public void Cvi_ConstantRange_ProducesZeroCvi()
{
var cvi = new Cvi(10, 10);
// Feed constant range bars
for (int i = 0; i < 30; i++)
{
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 105.0, 95.0, 102.0, 1000.0 // Constant 10-point range
);
cvi.Update(bar);
}
// Constant range means EMA_t = EMA_{t-rocLength}, so ROC = 0
Assert.Equal(0.0, cvi.Last.Value, 5);
}
/// <summary>
/// Validates expanding range produces positive CVI.
/// </summary>
[Fact]
public void Cvi_ExpandingRange_ProducesPositiveCvi()
{
var cvi = new Cvi(5, 5);
// Gradually expanding range
for (int i = 0; i < 20; i++)
{
double range = 5 + i * 0.5; // Expanding from 5 to 14.5
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 100.0 + range / 2, 100.0 - range / 2, 100.0, 1000.0
);
cvi.Update(bar);
}
// Expanding range should produce positive CVI (EMA increasing)
Assert.True(cvi.Last.Value > 0, "Expanding range should produce positive CVI");
}
/// <summary>
/// Validates contracting range produces negative CVI.
/// </summary>
[Fact]
public void Cvi_ContractingRange_ProducesNegativeCvi()
{
var cvi = new Cvi(5, 5);
// Gradually contracting range
for (int i = 0; i < 20; i++)
{
double range = 20 - i * 0.5; // Contracting from 20 to 10.5
if (range < 1)
{
range = 1;
}
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 100.0 + range / 2, 100.0 - range / 2, 100.0, 1000.0
);
cvi.Update(bar);
}
// Contracting range should produce negative CVI (EMA decreasing)
Assert.True(cvi.Last.Value < 0, "Contracting range should produce negative CVI");
}
/// <summary>
/// Validates manual CVI calculation matches implementation.
/// </summary>
[Fact]
public void Cvi_ManualCalculation_MatchesImplementation()
{
int rocLength = 3;
int smoothLength = 3;
double alpha = 2.0 / (smoothLength + 1); // 0.5
// Fixed range values
double[] ranges = { 10.0, 12.0, 11.0, 13.0, 15.0, 14.0, 16.0, 18.0, 17.0, 19.0 };
// Calculate EMA manually
double[] emas = new double[ranges.Length];
emas[0] = ranges[0];
for (int i = 1; i < ranges.Length; i++)
{
emas[i] = (ranges[i] - emas[i - 1]) * alpha + emas[i - 1];
}
// Calculate ROC for last point
int lastIdx = ranges.Length - 1;
double oldEma = emas[lastIdx - rocLength];
double currentEma = emas[lastIdx];
double expectedCvi = ((currentEma - oldEma) / oldEma) * 100.0;
// Calculate using indicator
var cvi = new Cvi(rocLength, smoothLength);
for (int i = 0; i < ranges.Length; i++)
{
cvi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), ranges[i]));
}
Assert.Equal(expectedCvi, cvi.Last.Value, 8);
}
/// <summary>
/// Validates EMA smoothing property: EMA responds to recent values more.
/// </summary>
[Fact]
public void Cvi_EmaSmoothingProperty_RecentValuesWeightedMore()
{
var cvi = new Cvi(5, 5);
// Feed stable values then spike
for (int i = 0; i < 15; i++)
{
cvi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 10.0));
}
double preSpikeValue = cvi.Last.Value;
// Single spike
cvi.Update(new TValue(DateTime.UtcNow.AddMinutes(15), 20.0));
double postSpikeValue = cvi.Last.Value;
// EMA should respond to spike (increasing CVI since range doubled)
Assert.True(postSpikeValue > preSpikeValue,
"EMA should respond to recent value changes");
}
// === Consistency Tests ===
/// <summary>
/// Validates streaming and batch produce identical results.
/// </summary>
[Fact]
public void Cvi_StreamingMatchesBatch()
{
var bars = GenerateTestData(100);
// Streaming calculation
var streamingCvi = new Cvi(10, 10);
for (int i = 0; i < bars.Count; i++)
{
streamingCvi.Update(bars[i]);
}
// Batch calculation
var batchResult = Cvi.Batch(bars, 10, 10);
// Compare last values
Assert.Equal(batchResult.Last.Value, streamingCvi.Last.Value, 8);
}
/// <summary>
/// Validates TBarSeries input matches TBar streaming.
/// </summary>
[Fact]
public void Cvi_TBarSeriesInput_MatchesStreaming()
{
var bars = GenerateTestData(100);
// Streaming
var streamingCvi = new Cvi(10, 10);
for (int i = 0; i < bars.Count; i++)
{
streamingCvi.Update(bars[i]);
}
// TBarSeries batch
var batchCvi = new Cvi(10, 10);
var batchResult = batchCvi.Update(bars);
Assert.Equal(batchResult.Last.Value, streamingCvi.Last.Value, 10);
}
/// <summary>
/// Validates Span batch matches streaming.
/// </summary>
[Fact]
public void Cvi_SpanBatch_MatchesStreaming()
{
var bars = GenerateTestData(100);
// Extract ranges from bars
var ranges = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
ranges[i] = bars[i].High - bars[i].Low;
}
// Streaming
var streamingCvi = new Cvi(10, 10);
for (int i = 0; i < bars.Count; i++)
{
streamingCvi.Update(new TValue(bars.Times[i], ranges[i]));
}
// Span batch
var output = new double[ranges.Length];
Cvi.Batch(ranges, output, 10, 10);
Assert.Equal(output[^1], streamingCvi.Last.Value, 10);
}
// === Parameter Sensitivity ===
/// <summary>
/// Validates shorter rocLength produces more volatile CVI.
/// </summary>
[Fact]
public void Cvi_ShorterRocLength_MoreVolatile()
{
var bars = GenerateTestData(100);
var cviShort = new Cvi(5, 10); // rocLength = 5
var cviLong = new Cvi(20, 10); // rocLength = 20
var shortResults = new List<double>();
var longResults = new List<double>();
for (int i = 0; i < bars.Count; i++)
{
cviShort.Update(bars[i]);
cviLong.Update(bars[i]);
if (cviShort.IsHot && cviLong.IsHot)
{
shortResults.Add(cviShort.Last.Value);
longResults.Add(cviLong.Last.Value);
}
}
// Shorter rocLength should generally produce more volatile CVI values
// (comparing values over fewer periods)
Assert.True(shortResults.Count > 0, "Should have hot results");
}
/// <summary>
/// Validates shorter smoothLength produces faster response.
/// </summary>
[Fact]
public void Cvi_ShorterSmoothLength_FasterResponse()
{
var cviShort = new Cvi(10, 5); // smoothLength = 5
var cviLong = new Cvi(10, 20); // smoothLength = 20
// Feed stable values
for (int i = 0; i < 30; i++)
{
cviShort.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 10.0));
cviLong.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 10.0));
}
double preShortValue = cviShort.Last.Value;
double preLongValue = cviLong.Last.Value;
// Spike in range
cviShort.Update(new TValue(DateTime.UtcNow.AddMinutes(30), 20.0));
cviLong.Update(new TValue(DateTime.UtcNow.AddMinutes(30), 20.0));
double changeShort = Math.Abs(cviShort.Last.Value - preShortValue);
double changeLong = Math.Abs(cviLong.Last.Value - preLongValue);
// Shorter smoothLength should show larger immediate change
Assert.True(changeShort > changeLong,
"Shorter smoothLength should respond faster to changes");
}
/// <summary>
/// Validates different parameter combinations produce different results.
/// </summary>
[Fact]
public void Cvi_DifferentParameters_ProduceDifferentResults()
{
var bars = GenerateTestData(50);
var cvi1 = new Cvi(10, 10);
var cvi2 = new Cvi(14, 10);
var cvi3 = new Cvi(10, 14);
for (int i = 0; i < bars.Count; i++)
{
cvi1.Update(bars[i]);
cvi2.Update(bars[i]);
cvi3.Update(bars[i]);
}
// Different parameters should produce different values
Assert.NotEqual(cvi1.Last.Value, cvi2.Last.Value);
Assert.NotEqual(cvi1.Last.Value, cvi3.Last.Value);
}
// === Edge Cases ===
/// <summary>
/// Validates handling of very small ranges.
/// </summary>
[Fact]
public void Cvi_VerySmallRanges_HandledCorrectly()
{
var cvi = new Cvi(5, 5);
for (int i = 0; i < 20; i++)
{
// Very small range (0.0001)
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 100.00005, 99.99995, 100.0, 1000.0
);
cvi.Update(bar);
}
Assert.True(double.IsFinite(cvi.Last.Value));
}
/// <summary>
/// Validates handling of very large ranges.
/// </summary>
[Fact]
public void Cvi_VeryLargeRanges_HandledCorrectly()
{
var cvi = new Cvi(5, 5);
for (int i = 0; i < 20; i++)
{
// Large range
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 200.0, 50.0, 150.0, 1000.0
);
cvi.Update(bar);
}
Assert.True(double.IsFinite(cvi.Last.Value));
}
/// <summary>
/// Validates handling of alternating large/small ranges.
/// </summary>
[Fact]
public void Cvi_AlternatingRanges_HandledCorrectly()
{
var cvi = new Cvi(5, 5);
for (int i = 0; i < 20; i++)
{
double range = (i % 2 == 0) ? 5.0 : 20.0;
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 100.0 + range / 2, 100.0 - range / 2, 100.0, 1000.0
);
cvi.Update(bar);
}
Assert.True(double.IsFinite(cvi.Last.Value));
}
/// <summary>
/// Validates warmup period calculation.
/// </summary>
[Theory]
[InlineData(10, 10, 20)]
[InlineData(14, 10, 24)]
[InlineData(5, 20, 25)]
public void Cvi_WarmupPeriod_IsCorrect(int rocLength, int smoothLength, int expectedWarmup)
{
var cvi = new Cvi(rocLength, smoothLength);
Assert.Equal(expectedWarmup, cvi.WarmupPeriod);
}
/// <summary>
/// Validates output range is reasonable for typical market data.
/// </summary>
[Fact]
public void Cvi_OutputRange_IsReasonable()
{
var bars = GenerateTestData(100);
var cvi = new Cvi(10, 10);
for (int i = 0; i < bars.Count; i++)
{
cvi.Update(bars[i]);
}
// CVI is a percentage ROC, typically between -100% and +100% for normal markets
// Extreme values possible but rare
Assert.True(cvi.Last.Value > -500, "CVI should be > -500%");
Assert.True(cvi.Last.Value < 500, "CVI should be < +500%");
}
/// <summary>
/// Validates CVI sign indicates volatility direction.
/// </summary>
[Fact]
public void Cvi_Sign_IndicatesVolatilityDirection()
{
// Test expanding volatility
var cviExpanding = new Cvi(5, 5);
for (int i = 0; i < 15; i++)
{
double range = 5 + i; // Expanding
cviExpanding.Update(new TValue(DateTime.UtcNow.AddMinutes(i), range));
}
// Test contracting volatility
var cviContracting = new Cvi(5, 5);
for (int i = 0; i < 15; i++)
{
double range = 20 - i; // Contracting
if (range < 1)
{
range = 1;
}
cviContracting.Update(new TValue(DateTime.UtcNow.AddMinutes(i), range));
}
Assert.True(cviExpanding.Last.Value > 0, "Expanding volatility should produce positive CVI");
Assert.True(cviContracting.Last.Value < 0, "Contracting volatility should produce negative CVI");
}
/// <summary>
/// Validates bar correction works correctly.
/// </summary>
[Fact]
public void Cvi_BarCorrection_WorksCorrectly()
{
var cvi = new Cvi(5, 5);
var bars = GenerateTestData(20);
// Feed initial bars
for (int i = 0; i < 15; i++)
{
cvi.Update(bars[i], isNew: true);
}
// Add new bar
cvi.Update(bars[15], isNew: true);
double afterNew = cvi.Last.Value;
// Correct with different range
var correctedBar = new TBar(
bars[15].Time,
100, 200, 50, 150, 1000 // Very different range
);
cvi.Update(correctedBar, isNew: false);
double afterCorrection = cvi.Last.Value;
// Restore original
cvi.Update(bars[15], isNew: false);
double afterRestore = cvi.Last.Value;
Assert.NotEqual(afterNew, afterCorrection);
Assert.Equal(afterNew, afterRestore, 10);
}
// === Tulip Cross-Validation ===
/// <summary>
/// Structural validation against Tulip <c>cvi</c> indicator.
/// Algorithm variant: Tulip <c>cvi</c> uses a single <c>period</c> for both the EMA
/// smoothing window and the ROC lookback, while QuanTAlib uses separate
/// <c>rocLength</c> and <c>smoothLength</c> parameters.
/// Direct numeric equality is not asserted; test documents the difference and
/// verifies both implementations produce finite, bounded output on the same data.
/// </summary>
[Fact]
public void Cvi_Tulip_StructuralVariant_BothFinite()
{
const int period = 10;
var bars = GenerateTestData(200);
double[] highData = new double[bars.Count];
double[] lowData = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
highData[i] = bars[i].High;
lowData[i] = bars[i].Low;
}
// QuanTAlib CVI — rocLength=period, smoothLength=period (closest equivalent)
_ = Cvi.Batch(bars, rocLength: period, smoothLength: period);
// Tulip cvi — single period covers both EMA smoothing and ROC lookback
var tulipIndicator = Tulip.Indicators.cvi;
double[][] inputs = { highData, lowData };
double[] options = { period };
int lookback = tulipIndicator.Start(options);
double[][] outputs = { new double[highData.Length - lookback] };
tulipIndicator.Run(inputs, options, outputs);
double[] tResult = outputs[0];
// Structural check: both produce finite output (algorithm variants differ in seeding)
Assert.True(tResult.Length > 0, "Tulip cvi must produce output");
foreach (double v in tResult)
{
Assert.True(double.IsFinite(v), $"Tulip cvi produced non-finite value: {v}");
}
// QuanTAlib IsHot lives on the indicator, not on TValue
var cviIndicator = new Cvi(rocLength: period, smoothLength: period);
foreach (var bar in bars) { cviIndicator.Update(bar); }
Assert.True(cviIndicator.IsHot, "QuanTAlib Cvi must be hot after sufficient bars");
}
// ── Cross-library: OoplesFinance ────────────────────────────────────
/// <summary>
/// Structural validation against Ooples <c>CalculateChandeVolatilityIndexDynamicAverageIndicator</c>.
/// NOTE: Ooples "CVI" is the Chande Volatility Index Dynamic Average (VIDA) — an adaptive
/// moving average that uses CVI as its volatility measure. QuanTAlib CVI is Chaikin's
/// Volatility Index: EMA(High-Low range) rate-of-change over rocLength bars. These are
/// different algorithms sharing the "CVI" abbreviation. Numeric equality is not expected.
/// Both must produce finite output on the same OHLCV data.
/// </summary>
[Fact]
public void Cvi_OoplesStructuralVariant_BothFinite()
{
const int length = 10;
var bars = GenerateTestData(200);
var ooplesData = new List<TickerData>();
foreach (var bar in bars)
{
ooplesData.Add(new TickerData
{
Date = new DateTime(bar.Time, DateTimeKind.Utc),
Open = bar.Open,
High = bar.High,
Low = bar.Low,
Close = bar.Close,
Volume = bar.Volume
});
}
var stockData = new StockData(ooplesData);
var oResult = stockData.CalculateChandeVolatilityIndexDynamicAverageIndicator(length: length);
var oValues = oResult.OutputValues.Values.First();
var cvi = new Cvi(rocLength: length, smoothLength: length);
foreach (var bar in bars) { cvi.Update(bar); }
int finiteCount = 0;
int warmup = length * 2;
for (int i = warmup; i < Math.Min(oValues.Count, bars.Count); i++)
{
if (double.IsFinite(oValues[i])) { finiteCount++; }
}
Assert.True(oValues.Count > 0, "Ooples CVI (VIDA) must produce output");
Assert.True(finiteCount > 50, $"Expected >50 finite Ooples CVI values, got {finiteCount}");
Assert.True(cvi.IsHot, "QuanTAlib CVI must be hot after 200 bars");
}
// === 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));
}
}