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
This commit is contained in:
Miha Kralj
2026-03-12 12:34:16 -07:00
parent 8937b0c0fa
commit 060649192f
1149 changed files with 1780 additions and 3316 deletions
@@ -0,0 +1,304 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class GkvIndicatorTests
{
[Fact]
public void GkvIndicator_Constructor_SetsDefaults()
{
var indicator = new GkvIndicator();
Assert.Equal(20, indicator.Period);
Assert.True(indicator.Annualize);
Assert.Equal(252, indicator.AnnualPeriods);
Assert.True(indicator.ShowColdValues);
Assert.Equal("GKV - Garman-Klass Volatility", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void GkvIndicator_ShortName_IncludesParameters()
{
var indicator = new GkvIndicator { Period = 14 };
Assert.Contains("GKV", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("14", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void GkvIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new GkvIndicator();
Assert.Equal(0, GkvIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void GkvIndicator_Initialize_CreatesInternalGkv()
{
var indicator = new GkvIndicator();
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void GkvIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new GkvIndicator { Period = 10 };
indicator.Initialize();
// Add historical data with varying volatility
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
double basePrice = 100 + i;
double range = 2 + (i % 5); // Varying ranges
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + range, basePrice - range, basePrice + 1, 1000);
// Process update for each bar to simulate history loading
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
// Line series should have a value
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val));
Assert.True(val >= 0, "Volatility should be non-negative");
}
[Fact]
public void GkvIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new GkvIndicator { Period = 10 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
double basePrice = 100 + i;
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 2, 1000);
}
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Add new bar with larger range
indicator.HistoricalData.AddBar(now.AddMinutes(30), 120, 135, 105, 125, 1500);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void GkvIndicator_DifferentPeriods_Work()
{
int[] periods = { 5, 10, 14, 20 };
foreach (var period in periods)
{
var indicator = new GkvIndicator { Period = period };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 50; i++)
{
double basePrice = 100 + i;
double range = 3 + (i % 4);
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + range, basePrice - range, basePrice + 1, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val), $"Period {period} should produce finite value");
Assert.True(val >= 0, $"Period {period} should produce non-negative value");
}
}
[Fact]
public void GkvIndicator_Period_CanBeChanged()
{
var indicator = new GkvIndicator();
Assert.Equal(20, indicator.Period);
indicator.Period = 14;
Assert.Equal(14, indicator.Period);
indicator.Period = 10;
Assert.Equal(10, indicator.Period);
}
[Fact]
public void GkvIndicator_Annualize_CanBeToggled()
{
var indicator = new GkvIndicator();
Assert.True(indicator.Annualize);
indicator.Annualize = false;
Assert.False(indicator.Annualize);
indicator.Annualize = true;
Assert.True(indicator.Annualize);
}
[Fact]
public void GkvIndicator_AnnualPeriods_CanBeChanged()
{
var indicator = new GkvIndicator();
Assert.Equal(252, indicator.AnnualPeriods);
indicator.AnnualPeriods = 365;
Assert.Equal(365, indicator.AnnualPeriods);
indicator.AnnualPeriods = 52;
Assert.Equal(52, indicator.AnnualPeriods);
}
[Fact]
public void GkvIndicator_ShowColdValues_CanBeToggled()
{
var indicator = new GkvIndicator();
Assert.True(indicator.ShowColdValues);
indicator.ShowColdValues = false;
Assert.False(indicator.ShowColdValues);
indicator.ShowColdValues = true;
Assert.True(indicator.ShowColdValues);
}
[Fact]
public void GkvIndicator_SourceCodeLink_IsValid()
{
var indicator = new GkvIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Gkv.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void GkvIndicator_HighVolatility_ProducesHigherValue()
{
var indicator1 = new GkvIndicator { Period = 10, Annualize = false };
var indicator2 = new GkvIndicator { Period = 10, Annualize = false };
indicator1.Initialize();
indicator2.Initialize();
var now = DateTime.UtcNow;
// Indicator 1: low volatility (narrow range)
for (int i = 0; i < 30; i++)
{
double basePrice = 100;
indicator1.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 1, basePrice - 1, basePrice + 0.5, 1000);
indicator1.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
// Indicator 2: high volatility (wide range)
for (int i = 0; i < 30; i++)
{
double basePrice = 100;
indicator2.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 10, basePrice - 10, basePrice + 2, 1000);
indicator2.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double lowVol = indicator1.LinesSeries[0].GetValue(0);
double highVol = indicator2.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(lowVol));
Assert.True(double.IsFinite(highVol));
Assert.True(highVol > lowVol, "Higher volatility bars should produce higher GKV value");
}
[Fact]
public void GkvIndicator_AnnualizedValue_IsScaled()
{
var indicatorRaw = new GkvIndicator { Period = 10, Annualize = false };
var indicatorAnn = new GkvIndicator { Period = 10, Annualize = true, AnnualPeriods = 252 };
indicatorRaw.Initialize();
indicatorAnn.Initialize();
var now = DateTime.UtcNow;
// Same data for both
for (int i = 0; i < 30; i++)
{
double basePrice = 100 + i * 0.5;
indicatorRaw.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 3, basePrice - 3, basePrice + 1, 1000);
indicatorRaw.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicatorAnn.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 3, basePrice - 3, basePrice + 1, 1000);
indicatorAnn.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double rawValue = indicatorRaw.LinesSeries[0].GetValue(0);
double annValue = indicatorAnn.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(rawValue));
Assert.True(double.IsFinite(annValue));
// Annualized should be approximately sqrt(252) times larger
double expectedRatio = Math.Sqrt(252);
double actualRatio = annValue / rawValue;
Assert.True(Math.Abs(actualRatio - expectedRatio) < 0.01,
$"Annualized value should be ~{expectedRatio:F2}× raw, got {actualRatio:F2}×");
}
[Fact]
public void GkvIndicator_UsesAllOhlcPrices()
{
// Test that GKV uses all 4 prices (OHLC)
var indicator1 = new GkvIndicator { Period = 10, Annualize = false };
var indicator2 = new GkvIndicator { Period = 10, Annualize = false };
indicator1.Initialize();
indicator2.Initialize();
var now = DateTime.UtcNow;
// Same high/low range but different open/close
for (int i = 0; i < 30; i++)
{
// Indicator 1: open = close (doji pattern)
indicator1.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 100, 1000);
indicator1.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Indicator 2: open != close (directional move)
indicator2.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 104, 1000);
indicator2.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val1 = indicator1.LinesSeries[0].GetValue(0);
double val2 = indicator2.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val1));
Assert.True(double.IsFinite(val2));
// GKV uses close-open term, so values should differ
Assert.NotEqual(val1, val2);
}
[Fact]
public void GkvIndicator_ConstantPrice_ProducesZeroVolatility()
{
var indicator = new GkvIndicator { Period = 10, Annualize = false };
indicator.Initialize();
var now = DateTime.UtcNow;
// Constant price (no volatility)
for (int i = 0; i < 30; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 100, 100, 100, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val));
Assert.True(val < 0.001, "Constant price should produce near-zero volatility");
}
}
+646
View File
@@ -0,0 +1,646 @@
namespace QuanTAlib.Tests;
using Xunit;
public class GkvTests
{
private const double Tolerance = 1e-9;
private static TBarSeries GenerateTestData(int count = 100)
{
var gbm = new GBM(seed: 42);
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
}
#region Constructor Tests
[Fact]
public void Constructor_DefaultParameters_SetsCorrectValues()
{
var gkv = new Gkv();
Assert.Equal(20, gkv.Period);
Assert.True(gkv.Annualize);
Assert.Equal(252, gkv.AnnualPeriods);
Assert.Equal("Gkv(20)", gkv.Name);
Assert.Equal(20, gkv.WarmupPeriod);
}
[Fact]
public void Constructor_CustomParameters_SetsCorrectValues()
{
var gkv = new Gkv(period: 10, annualize: false, annualPeriods: 365);
Assert.Equal(10, gkv.Period);
Assert.False(gkv.Annualize);
Assert.Equal(365, gkv.AnnualPeriods);
Assert.Equal("Gkv(10)", gkv.Name);
}
[Fact]
public void Constructor_ZeroPeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Gkv(period: 0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_NegativePeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Gkv(period: -1));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_ZeroAnnualPeriodsWhenAnnualizing_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Gkv(period: 10, annualize: true, annualPeriods: 0));
Assert.Equal("annualPeriods", ex.ParamName);
}
[Fact]
public void Constructor_ZeroAnnualPeriodsWhenNotAnnualizing_DoesNotThrow()
{
var gkv = new Gkv(period: 10, annualize: false, annualPeriods: 0);
Assert.Equal(0, gkv.AnnualPeriods);
}
#endregion
#region Basic Calculation Tests
[Fact]
public void Update_SingleBar_ReturnsNonNegativeValue()
{
var gkv = new Gkv(period: 5);
var bar = new TBar(DateTime.UtcNow, 100.0, 105.0, 98.0, 102.0, 1000);
var result = gkv.Update(bar);
Assert.True(result.Value >= 0, "GKV should return non-negative values");
}
[Fact]
public void Update_MultipleBars_ReturnsCorrectCount()
{
var gkv = new Gkv(period: 5);
var bars = GenerateTestData(10);
for (int i = 0; i < bars.Count; i++)
{
gkv.Update(bars[i]);
}
Assert.True(gkv.IsHot, "Indicator should be hot after warmup period");
}
[Fact]
public void Update_ReturnsLastValue()
{
var gkv = new Gkv(period: 5);
var bar = new TBar(DateTime.UtcNow, 100.0, 105.0, 98.0, 102.0, 1000);
var result = gkv.Update(bar);
Assert.Equal(result.Value, gkv.Last.Value, Tolerance);
}
[Fact]
public void Update_WithoutAnnualization_ReturnsSmallerValues()
{
var gkvAnnual = new Gkv(period: 10, annualize: true, annualPeriods: 252);
var gkvNoAnnual = new Gkv(period: 10, annualize: false);
var bars = GenerateTestData(20);
double lastAnnual = 0;
double lastNoAnnual = 0;
for (int i = 0; i < bars.Count; i++)
{
lastAnnual = gkvAnnual.Update(bars[i]).Value;
lastNoAnnual = gkvNoAnnual.Update(bars[i]).Value;
}
// Annualized values should be larger by factor of sqrt(252)
Assert.True(lastAnnual > lastNoAnnual, "Annualized values should be larger");
}
#endregion
#region State Management Tests
[Fact]
public void Update_IsNewTrue_AdvancesState()
{
var gkv = new Gkv(period: 5);
var bar1 = new TBar(DateTime.UtcNow, 100.0, 105.0, 98.0, 102.0, 1000);
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 102.0, 107.0, 100.0, 105.0, 1000);
gkv.Update(bar1, isNew: true);
var result1 = gkv.Last.Value;
gkv.Update(bar2, isNew: true);
var result2 = gkv.Last.Value;
Assert.NotEqual(result1, result2);
}
[Fact]
public void Update_IsNewFalse_UpdatesCurrentBar()
{
var gkv = new Gkv(period: 5);
var bar1 = new TBar(DateTime.UtcNow, 100.0, 105.0, 98.0, 102.0, 1000);
gkv.Update(bar1, isNew: true);
var firstValue = gkv.Last.Value;
// Update the same bar with different values
var bar1Updated = new TBar(DateTime.UtcNow, 100.0, 110.0, 95.0, 108.0, 1000);
gkv.Update(bar1Updated, isNew: false);
var updatedValue = gkv.Last.Value;
Assert.NotEqual(firstValue, updatedValue);
}
[Fact]
public void Update_IterativeCorrections_RestoresState()
{
var gkv = new Gkv(period: 5);
var bars = GenerateTestData(10);
// Process first 5 bars
for (int i = 0; i < 5; i++)
{
gkv.Update(bars[i], isNew: true);
}
// Add bar 6 and correct multiple times
gkv.Update(bars[5], isNew: true);
gkv.Update(bars[5], isNew: false);
gkv.Update(bars[5], isNew: false);
gkv.Update(bars[5], isNew: false);
// Now continue with bar 7
gkv.Update(bars[6], isNew: true);
// Create new instance and process same data
var gkv2 = new Gkv(period: 5);
for (int i = 0; i < 7; i++)
{
gkv2.Update(bars[i], isNew: true);
}
Assert.Equal(gkv.Last.Value, gkv2.Last.Value, Tolerance);
}
#endregion
#region IsHot and Warmup Tests
[Fact]
public void IsHot_BeforeWarmup_ReturnsFalse()
{
var gkv = new Gkv(period: 10);
var bars = GenerateTestData(5);
for (int i = 0; i < bars.Count; i++)
{
gkv.Update(bars[i]);
}
Assert.False(gkv.IsHot);
}
[Fact]
public void IsHot_AfterWarmup_ReturnsTrue()
{
var gkv = new Gkv(period: 10);
var bars = GenerateTestData(15);
for (int i = 0; i < bars.Count; i++)
{
gkv.Update(bars[i]);
}
Assert.True(gkv.IsHot);
}
[Fact]
public void IsHot_ExactlyAtWarmup_ReturnsTrue()
{
var gkv = new Gkv(period: 10);
var bars = GenerateTestData(10);
for (int i = 0; i < bars.Count; i++)
{
gkv.Update(bars[i]);
}
Assert.True(gkv.IsHot);
}
#endregion
#region Reset Tests
[Fact]
public void Reset_ClearsState()
{
var gkv = new Gkv(period: 5);
var bars = GenerateTestData(10);
for (int i = 0; i < bars.Count; i++)
{
gkv.Update(bars[i]);
}
gkv.Reset();
Assert.False(gkv.IsHot);
Assert.Equal(0, gkv.Last.Value);
}
[Fact]
public void Reset_AllowsReprocessing()
{
var gkv = new Gkv(period: 5);
var bars = GenerateTestData(10);
// First pass
for (int i = 0; i < bars.Count; i++)
{
gkv.Update(bars[i]);
}
var firstResult = gkv.Last.Value;
// Reset and second pass
gkv.Reset();
for (int i = 0; i < bars.Count; i++)
{
gkv.Update(bars[i]);
}
var secondResult = gkv.Last.Value;
Assert.Equal(firstResult, secondResult, Tolerance);
}
#endregion
#region Robustness Tests
[Fact]
public void Update_WithNaNValues_UsesLastValidEstimator()
{
var gkv = new Gkv(period: 5);
var bars = GenerateTestData(10);
for (int i = 0; i < bars.Count; i++)
{
gkv.Update(bars[i]);
}
var valueBeforeInvalid = gkv.Last.Value;
// Bar with NaN close - should use last valid GK estimator
var nanBar = new TBar(DateTime.UtcNow, 100.0, 105.0, 98.0, double.NaN, 1000);
var result = gkv.Update(nanBar);
// Result should be finite and close to previous (RMA smoothed)
Assert.True(double.IsFinite(result.Value), "Result should be finite when using last valid estimator");
Assert.True(result.Value >= 0, "Volatility should be non-negative");
// Value should be similar (within 20% relative) since same estimator is used
double relativeDiff = Math.Abs(result.Value - valueBeforeInvalid) / valueBeforeInvalid;
Assert.True(relativeDiff < 0.2, $"Value should be similar to previous: {valueBeforeInvalid} vs {result.Value}");
}
[Fact]
public void Update_WithInfinityValues_UsesLastValidEstimator()
{
var gkv = new Gkv(period: 5);
var bars = GenerateTestData(10);
for (int i = 0; i < bars.Count; i++)
{
gkv.Update(bars[i]);
}
var valueBeforeInvalid = gkv.Last.Value;
// Bar with infinity - should use last valid GK estimator
var infBar = new TBar(DateTime.UtcNow, 100.0, double.PositiveInfinity, 98.0, 102.0, 1000);
var result = gkv.Update(infBar);
Assert.True(double.IsFinite(result.Value), "Result should be finite when using last valid estimator");
Assert.True(result.Value >= 0, "Volatility should be non-negative");
double relativeDiff = Math.Abs(result.Value - valueBeforeInvalid) / valueBeforeInvalid;
Assert.True(relativeDiff < 0.2, $"Value should be similar to previous: {valueBeforeInvalid} vs {result.Value}");
}
[Fact]
public void Update_WithZeroPrices_UsesLastValidEstimator()
{
var gkv = new Gkv(period: 5);
var bars = GenerateTestData(10);
for (int i = 0; i < bars.Count; i++)
{
gkv.Update(bars[i]);
}
var valueBeforeInvalid = gkv.Last.Value;
// Bar with zero close (invalid for log) - should use last valid GK estimator
var zeroBar = new TBar(DateTime.UtcNow, 100.0, 105.0, 98.0, 0.0, 1000);
var result = gkv.Update(zeroBar);
Assert.True(double.IsFinite(result.Value), "Result should be finite when using last valid estimator");
Assert.True(result.Value >= 0, "Volatility should be non-negative");
double relativeDiff = Math.Abs(result.Value - valueBeforeInvalid) / valueBeforeInvalid;
Assert.True(relativeDiff < 0.2, $"Value should be similar to previous: {valueBeforeInvalid} vs {result.Value}");
}
[Fact]
public void Update_WithNegativePrices_UsesLastValidEstimator()
{
var gkv = new Gkv(period: 5);
var bars = GenerateTestData(10);
for (int i = 0; i < bars.Count; i++)
{
gkv.Update(bars[i]);
}
var valueBeforeInvalid = gkv.Last.Value;
// Bar with negative price - should use last valid GK estimator
var negBar = new TBar(DateTime.UtcNow, 100.0, 105.0, -98.0, 102.0, 1000);
var result = gkv.Update(negBar);
Assert.True(double.IsFinite(result.Value), "Result should be finite when using last valid estimator");
Assert.True(result.Value >= 0, "Volatility should be non-negative");
double relativeDiff = Math.Abs(result.Value - valueBeforeInvalid) / valueBeforeInvalid;
Assert.True(relativeDiff < 0.2, $"Value should be similar to previous: {valueBeforeInvalid} vs {result.Value}");
}
#endregion
#region Batch and Series Tests
[Fact]
public void Batch_MatchesStreamingResults()
{
const int dataCount = 100;
var bars = GenerateTestData(dataCount);
// Streaming
var gkvStreaming = new Gkv(period: 10);
var streamingResults = new double[dataCount];
for (int i = 0; i < dataCount; i++)
{
streamingResults[i] = gkvStreaming.Update(bars[i]).Value;
}
// Batch
var opens = new double[dataCount];
var highs = new double[dataCount];
var lows = new double[dataCount];
var closes = new double[dataCount];
var batchResults = new double[dataCount];
for (int i = 0; i < dataCount; i++)
{
opens[i] = bars[i].Open;
highs[i] = bars[i].High;
lows[i] = bars[i].Low;
closes[i] = bars[i].Close;
}
Gkv.Batch(opens, highs, lows, closes, batchResults, period: 10);
// Compare last 50 values (after warmup)
for (int i = 50; i < dataCount; i++)
{
Assert.Equal(streamingResults[i], batchResults[i], Tolerance);
}
}
[Fact]
public void Calculate_TBarSeries_ReturnsCorrectLength()
{
const int dataCount = 50;
var barSeries = GenerateTestData(dataCount);
var result = Gkv.Batch(barSeries, period: 10);
Assert.Equal(dataCount, result.Count);
}
[Fact]
public void Update_TBarSeries_MatchesStreamingResults()
{
const int dataCount = 50;
var barSeries = GenerateTestData(dataCount);
// Series update
var gkvSeries = new Gkv(period: 10);
var seriesResult = gkvSeries.Update(barSeries);
// Streaming
var gkvStreaming = new Gkv(period: 10);
var streamingResults = new double[dataCount];
for (int i = 0; i < dataCount; i++)
{
streamingResults[i] = gkvStreaming.Update(barSeries[i]).Value;
}
// Compare last 30 values
for (int i = 20; i < dataCount; i++)
{
Assert.Equal(streamingResults[i], seriesResult.Values[i], Tolerance);
}
}
[Fact]
public void Batch_EmptyInput_DoesNotThrow()
{
var opens = Array.Empty<double>();
var highs = Array.Empty<double>();
var lows = Array.Empty<double>();
var closes = Array.Empty<double>();
var output = Array.Empty<double>();
// Should not throw
Gkv.Batch(opens, highs, lows, closes, output, period: 10);
Assert.Empty(output);
}
[Fact]
public void Batch_MismatchedLengths_ThrowsArgumentException()
{
var opens = new double[10];
var highs = new double[5]; // Mismatched
var lows = new double[10];
var closes = new double[10];
var output = new double[10];
var ex = Assert.Throws<ArgumentException>(() =>
Gkv.Batch(opens, highs, lows, closes, output, period: 10));
Assert.Equal("high", ex.ParamName);
}
[Fact]
public void Batch_OutputTooShort_ThrowsArgumentException()
{
var opens = new double[10];
var highs = new double[10];
var lows = new double[10];
var closes = new double[10];
var output = new double[5]; // Too short
var ex = Assert.Throws<ArgumentException>(() =>
Gkv.Batch(opens, highs, lows, closes, output, period: 10));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Batch_InvalidPeriod_ThrowsArgumentException()
{
var opens = new double[10];
var highs = new double[10];
var lows = new double[10];
var closes = new double[10];
var output = new double[10];
var ex = Assert.Throws<ArgumentException>(() =>
Gkv.Batch(opens, highs, lows, closes, output, period: 0));
Assert.Equal("period", ex.ParamName);
}
#endregion
#region Event Publishing Tests
[Fact]
public void Update_PublishesEvent()
{
var gkv = new Gkv(period: 5);
bool eventFired = false;
gkv.Pub += (object? sender, in TValueEventArgs args) => eventFired = true;
var bar = new TBar(DateTime.UtcNow, 100.0, 105.0, 98.0, 102.0, 1000);
gkv.Update(bar);
Assert.True(eventFired);
}
[Fact]
public void ChainedIndicator_ReceivesValues()
{
var source = new Gkv(period: 5);
var downstream = new Sma(source, period: 3);
var bars = GenerateTestData(10);
for (int i = 0; i < bars.Count; i++)
{
source.Update(bars[i]);
}
Assert.True(downstream.Last.Value > 0, "Downstream indicator should receive values");
}
#endregion
#region TValue Update Tests
[Fact]
public void Update_TValue_TreatsAsPrecomputedEstimator()
{
var gkv1 = new Gkv(period: 5);
var gkv2 = new Gkv(period: 5);
// For gkv1, use bar data
var bar = new TBar(DateTime.UtcNow, 100.0, 105.0, 98.0, 102.0, 1000);
gkv1.Update(bar);
// For gkv2, use pre-computed estimator value
// Compute manually: 0.5*(ln(105)-ln(98))^2 - 0.386294*(ln(102)-ln(100))^2
double lnH = Math.Log(105.0);
double lnL = Math.Log(98.0);
double lnO = Math.Log(100.0);
double lnC = Math.Log(102.0);
double term1 = 0.5 * Math.Pow(lnH - lnL, 2);
double term2 = 0.38629436111989061883 * Math.Pow(lnC - lnO, 2);
double gkEstimator = term1 - term2;
var tvalue = new TValue(bar.Time, gkEstimator);
gkv2.Update(tvalue);
Assert.Equal(gkv1.Last.Value, gkv2.Last.Value, Tolerance);
}
#endregion
#region Additional Tests
[Fact]
public void LargeDataset_Performance()
{
var gkv = new Gkv(period: 20);
var bars = GenerateTestData(5000);
for (int i = 0; i < bars.Count; i++)
{
var result = gkv.Update(bars[i]);
Assert.True(double.IsFinite(result.Value));
}
}
[Fact]
public void DifferentParameters_ProduceDistinctValues()
{
var bars = GenerateTestData(50);
var gkv1 = new Gkv(period: 10);
var gkv2 = new Gkv(period: 20);
var gkv3 = new Gkv(period: 10, annualize: false);
for (int i = 0; i < bars.Count; i++)
{
gkv1.Update(bars[i]);
gkv2.Update(bars[i]);
gkv3.Update(bars[i]);
}
Assert.True(double.IsFinite(gkv1.Last.Value));
Assert.True(double.IsFinite(gkv2.Last.Value));
Assert.True(double.IsFinite(gkv3.Last.Value));
// Different parameters should produce different values
Assert.NotEqual(gkv1.Last.Value, gkv2.Last.Value);
Assert.NotEqual(gkv1.Last.Value, gkv3.Last.Value);
}
[Fact]
public void StaticCalculate_Works()
{
var bars = GenerateTestData(100);
var result = Gkv.Batch(bars, period: 14);
Assert.Equal(100, result.Count);
Assert.True(double.IsFinite(result[result.Count - 1].Value));
}
[Fact]
public void StaticCalculate_ValidatesInput()
{
var bars = GenerateTestData(10);
Assert.Throws<ArgumentException>(() => Gkv.Batch(bars, period: 0));
Assert.Throws<ArgumentException>(() => Gkv.Batch(bars, period: -1));
Assert.Throws<ArgumentException>(() => Gkv.Batch(bars, period: 10, annualize: true, annualPeriods: 0));
}
[Fact]
public void Prime_Works()
{
var gkv = new Gkv(period: 5);
var values = new double[] { 0.001, 0.002, 0.0015, 0.0018, 0.0012, 0.0022 };
gkv.Prime(values);
Assert.True(gkv.IsHot);
Assert.True(double.IsFinite(gkv.Last.Value));
}
#endregion
}
@@ -0,0 +1,628 @@
namespace QuanTAlib.Test;
using Xunit;
/// <summary>
/// Validation tests for GKV (Garman-Klass Volatility).
/// GKV is a range-based volatility estimator using OHLC data.
/// Formula: term1 = 0.5 × (lnH - lnL)², term2 = (2×ln(2)-1) × (lnC - lnO)²
/// GK Estimator = term1 - term2
/// RMA smoothing with bias correction applied.
/// </summary>
public class GkvValidationTests
{
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 Garman-Klass coefficient: (2×ln(2)-1) ≈ 0.38629436
/// </summary>
[Fact]
public void Gkv_GarmanKlassCoefficient_IsCorrect()
{
double expectedCoeff = 2.0 * Math.Log(2) - 1.0;
Assert.Equal(0.38629436111989, expectedCoeff, 10);
}
/// <summary>
/// Validates RMA decay formula: decay = 1 - (1/period)
/// </summary>
[Theory]
[InlineData(14, 0.928571428571429)] // 1 - 1/14 = 13/14
[InlineData(20, 0.95)] // 1 - 1/20 = 19/20
[InlineData(10, 0.9)] // 1 - 1/10 = 9/10
public void Gkv_RmaDecay_IsCorrect(int period, double expectedDecay)
{
double decay = 1.0 - 1.0 / period;
Assert.Equal(expectedDecay, decay, 10);
}
/// <summary>
/// Validates GK estimator formula: 0.5×(lnH-lnL)² - (2ln2-1)×(lnC-lnO)²
/// </summary>
[Fact]
public void Gkv_GkEstimatorFormula_IsCorrect()
{
double open = 100.0;
double high = 105.0;
double low = 95.0;
double close = 102.0;
double lnH = Math.Log(high);
double lnL = Math.Log(low);
double lnO = Math.Log(open);
double lnC = Math.Log(close);
double term1 = 0.5 * Math.Pow(lnH - lnL, 2);
double coeff = 2.0 * Math.Log(2) - 1.0;
double term2 = coeff * Math.Pow(lnC - lnO, 2);
double expectedGk = term1 - term2;
// Manual calculation
// lnH - lnL = ln(105/95) ≈ 0.1001
// term1 = 0.5 × 0.1001² ≈ 0.00501
// lnC - lnO = ln(102/100) ≈ 0.0198
// term2 = 0.386 × 0.0198² ≈ 0.000151
// GK ≈ 0.00501 - 0.000151 ≈ 0.00486
Assert.True(expectedGk > 0, "GK estimator should be positive for normal bars");
Assert.True(expectedGk < 0.1, "GK estimator should be small for 5% range");
}
/// <summary>
/// Validates that flat bar (O=H=L=C) produces zero GK estimator.
/// </summary>
[Fact]
public void Gkv_FlatBar_ProducesZeroGk()
{
double price = 100.0;
double lnH = Math.Log(price);
double lnL = Math.Log(price);
double lnO = Math.Log(price);
double lnC = Math.Log(price);
double term1 = 0.5 * Math.Pow(lnH - lnL, 2); // 0
double coeff = 2.0 * Math.Log(2) - 1.0;
double term2 = coeff * Math.Pow(lnC - lnO, 2); // 0
double gk = term1 - term2;
Assert.Equal(0.0, gk, 15);
}
/// <summary>
/// Validates bias correction formula: corrected = raw / (1 - decay^n)
/// </summary>
[Theory]
[InlineData(14, 5)] // Early in warmup
[InlineData(14, 14)] // At warmup
[InlineData(14, 50)] // Well past warmup
[InlineData(14, 100)] // Very late - correction should be minimal
public void Gkv_BiasCorrection_WorksCorrectly(int period, int count)
{
double decay = 1.0 - 1.0 / period;
double e = Math.Pow(decay, count);
double correctionFactor = 1.0 / (1.0 - e);
// Early: large correction needed
// Later: correction approaches 1.0
if (count < period)
{
Assert.True(correctionFactor > 1.05, "Early values should need significant correction");
}
else if (count > period * 5)
{
// For period=14, count=100: decay^100 ≈ 0.0003, factor ≈ 1.0003
Assert.True(correctionFactor < 1.01, "Very late values should need minimal correction");
}
else if (count > period * 2)
{
// For period=14, count=50: decay^50 ≈ 0.02, factor ≈ 1.02
Assert.True(correctionFactor < 1.1, "Late values should need small correction");
}
}
/// <summary>
/// Validates annualization factor: √(annualPeriods)
/// </summary>
[Theory]
[InlineData(252, 15.8745078663875)] // Daily trading days
[InlineData(365, 19.1049731745428)] // Calendar days
[InlineData(52, 7.21110255092798)] // Weekly
[InlineData(12, 3.46410161513775)] // Monthly
public void Gkv_AnnualizationFactor_IsCorrect(int annualPeriods, double expectedFactor)
{
double factor = Math.Sqrt(annualPeriods);
Assert.Equal(expectedFactor, factor, 10);
}
/// <summary>
/// Validates that wider range produces higher GK estimator.
/// </summary>
[Fact]
public void Gkv_WiderRange_ProducesHigherGk()
{
// Narrow range bar
double narrowGk = ComputeGkEstimator(100, 101, 99, 100);
// Wide range bar
double wideGk = ComputeGkEstimator(100, 110, 90, 100);
Assert.True(wideGk > narrowGk,
"Wider range should produce higher GK estimator");
}
/// <summary>
/// Validates that close-to-open move reduces GK estimator.
/// The term2 is subtracted, so larger (C-O) reduces GK.
/// </summary>
[Fact]
public void Gkv_LargeCloseOpenMove_ReducesGk()
{
// Same range, small close-open
double gkSmallMove = ComputeGkEstimator(100, 105, 95, 100.5);
// Same range, large close-open (close at high)
double gkLargeMove = ComputeGkEstimator(100, 105, 95, 104.5);
Assert.True(gkSmallMove > gkLargeMove,
"Larger close-open move should reduce GK estimator (term2 subtracted)");
}
// === Consistency Tests ===
/// <summary>
/// Validates streaming and batch produce identical results.
/// </summary>
[Fact]
public void Gkv_StreamingMatchesBatch()
{
var bars = GenerateTestData(100);
// Streaming calculation
var streamingGkv = new Gkv(14);
for (int i = 0; i < bars.Count; i++)
{
streamingGkv.Update(bars[i]);
}
// Batch calculation
var batchResult = Gkv.Batch(bars, 14);
// Compare last values
Assert.Equal(batchResult.Last.Value, streamingGkv.Last.Value, 8);
}
/// <summary>
/// Validates TBarSeries input matches TBar streaming.
/// </summary>
[Fact]
public void Gkv_TBarSeriesInput_MatchesStreaming()
{
var bars = GenerateTestData(100);
// Streaming
var streamingGkv = new Gkv(14);
for (int i = 0; i < bars.Count; i++)
{
streamingGkv.Update(bars[i]);
}
// TBarSeries batch
var batchGkv = new Gkv(14);
var batchResult = batchGkv.Update(bars);
Assert.Equal(batchResult.Last.Value, streamingGkv.Last.Value, 10);
}
/// <summary>
/// Validates Span batch matches streaming.
/// </summary>
[Fact]
public void Gkv_SpanBatch_MatchesStreaming()
{
var bars = GenerateTestData(100);
// Streaming
var streamingGkv = new Gkv(14);
for (int i = 0; i < bars.Count; i++)
{
streamingGkv.Update(bars[i]);
}
// Extract OHLC arrays
var opens = new double[bars.Count];
var highs = new double[bars.Count];
var lows = new double[bars.Count];
var closes = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
opens[i] = bars[i].Open;
highs[i] = bars[i].High;
lows[i] = bars[i].Low;
closes[i] = bars[i].Close;
}
// Span batch
var output = new double[bars.Count];
Gkv.Batch(opens, highs, lows, closes, output, 14);
Assert.Equal(output[^1], streamingGkv.Last.Value, 10);
}
/// <summary>
/// Validates annualized output is scaled correctly.
/// </summary>
[Fact]
public void Gkv_Annualized_ScaledCorrectly()
{
var bars = GenerateTestData(50);
// Non-annualized
var gkvRaw = new Gkv(14, annualize: false);
// Annualized (default 252 periods)
var gkvAnn = new Gkv(14, annualize: true, annualPeriods: 252);
for (int i = 0; i < bars.Count; i++)
{
gkvRaw.Update(bars[i]);
gkvAnn.Update(bars[i]);
}
double expectedRatio = Math.Sqrt(252);
double actualRatio = gkvAnn.Last.Value / gkvRaw.Last.Value;
Assert.Equal(expectedRatio, actualRatio, 6);
}
// === Parameter Sensitivity ===
/// <summary>
/// Validates shorter period produces more responsive volatility.
/// </summary>
[Fact]
public void Gkv_ShorterPeriod_MoreResponsive()
{
var bars = GenerateTestData(50);
var gkvShort = new Gkv(5);
var gkvLong = new Gkv(20);
var shortResults = new List<double>();
var longResults = new List<double>();
for (int i = 0; i < bars.Count; i++)
{
gkvShort.Update(bars[i]);
gkvLong.Update(bars[i]);
if (gkvShort.IsHot && gkvLong.IsHot)
{
shortResults.Add(gkvShort.Last.Value);
longResults.Add(gkvLong.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.5,
"Shorter period should generally be more variable");
}
/// <summary>
/// Validates different periods produce different results.
/// </summary>
[Fact]
public void Gkv_DifferentPeriods_ProduceDifferentResults()
{
var bars = GenerateTestData(50);
var gkv10 = new Gkv(10);
var gkv14 = new Gkv(14);
var gkv20 = new Gkv(20);
for (int i = 0; i < bars.Count; i++)
{
gkv10.Update(bars[i]);
gkv14.Update(bars[i]);
gkv20.Update(bars[i]);
}
Assert.NotEqual(gkv10.Last.Value, gkv14.Last.Value);
Assert.NotEqual(gkv14.Last.Value, gkv20.Last.Value);
}
// === Edge Cases ===
/// <summary>
/// Validates handling of very small ranges (tight consolidation).
/// </summary>
[Fact]
public void Gkv_VerySmallRanges_HandledCorrectly()
{
var gkv = new Gkv(14);
for (int i = 0; i < 30; i++)
{
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 100.001, 99.999, 100.0, 1000.0
);
gkv.Update(bar);
}
Assert.True(double.IsFinite(gkv.Last.Value));
Assert.True(gkv.Last.Value >= 0, "Volatility should be non-negative");
}
/// <summary>
/// Validates handling of very large ranges (high volatility).
/// </summary>
[Fact]
public void Gkv_VeryLargeRanges_HandledCorrectly()
{
var gkv = new Gkv(14);
for (int i = 0; i < 30; i++)
{
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 200.0, 50.0, 150.0, 1000.0
);
gkv.Update(bar);
}
Assert.True(double.IsFinite(gkv.Last.Value));
Assert.True(gkv.Last.Value > 0, "High volatility should produce positive value");
}
/// <summary>
/// Validates handling of constant bars (zero volatility).
/// </summary>
[Fact]
public void Gkv_ConstantBars_ProducesMinimalVolatility()
{
var gkv = new Gkv(14);
for (int i = 0; i < 30; i++)
{
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 100.0, 100.0, 100.0, 1000.0
);
gkv.Update(bar);
}
Assert.True(double.IsFinite(gkv.Last.Value));
Assert.True(gkv.Last.Value < 0.001, "Constant price should produce near-zero volatility");
}
/// <summary>
/// Validates handling of doji bars (open = close).
/// </summary>
[Fact]
public void Gkv_DojiBars_HandledCorrectly()
{
var gkv = new Gkv(14);
for (int i = 0; i < 30; i++)
{
// Doji: open = close, but has range
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 105.0, 95.0, 100.0, 1000.0
);
gkv.Update(bar);
}
Assert.True(double.IsFinite(gkv.Last.Value));
Assert.True(gkv.Last.Value > 0, "Doji with range should have positive volatility");
}
/// <summary>
/// Validates warmup period calculation.
/// </summary>
[Theory]
[InlineData(10)]
[InlineData(14)]
[InlineData(20)]
public void Gkv_WarmupPeriod_IsCorrect(int period)
{
var gkv = new Gkv(period);
Assert.Equal(period, gkv.WarmupPeriod);
}
/// <summary>
/// Validates output is always non-negative (volatility property).
/// </summary>
[Fact]
public void Gkv_Output_IsNonNegative()
{
var bars = GenerateTestData(100);
var gkv = new Gkv(14);
for (int i = 0; i < bars.Count; i++)
{
gkv.Update(bars[i]);
if (gkv.IsHot)
{
Assert.True(gkv.Last.Value >= 0,
$"Volatility should be non-negative at bar {i}");
}
}
}
/// <summary>
/// Validates bar correction works correctly.
/// </summary>
[Fact]
public void Gkv_BarCorrection_WorksCorrectly()
{
var gkv = new Gkv(14);
var bars = GenerateTestData(30);
// Feed initial bars
for (int i = 0; i < 20; i++)
{
gkv.Update(bars[i], isNew: true);
}
// Add new bar
gkv.Update(bars[20], isNew: true);
double afterNew = gkv.Last.Value;
// Correct with different bar (much higher volatility)
var correctedBar = new TBar(
bars[20].Time,
100, 200, 50, 150, 1000
);
gkv.Update(correctedBar, isNew: false);
double afterCorrection = gkv.Last.Value;
// Restore original
gkv.Update(bars[20], isNew: false);
double afterRestore = gkv.Last.Value;
Assert.NotEqual(afterNew, afterCorrection);
Assert.Equal(afterNew, afterRestore, 10);
}
/// <summary>
/// Validates iterative corrections converge to same result.
/// </summary>
[Fact]
public void Gkv_IterativeCorrections_Converge()
{
var gkv = new Gkv(14);
var bars = GenerateTestData(30);
// Feed bars and make corrections
for (int i = 0; i < 20; i++)
{
gkv.Update(bars[i], isNew: true);
}
// Multiple corrections on same bar
for (int j = 0; j < 5; j++)
{
var tempBar = new TBar(
bars[19].Time,
100 + j, 110 + j, 90 + j, 105 + j, 1000
);
gkv.Update(tempBar, isNew: false);
}
// Final correction back to original
gkv.Update(bars[19], isNew: false);
double afterCorrections = gkv.Last.Value;
// Fresh calculation
var gkvFresh = new Gkv(14);
for (int i = 0; i < 20; i++)
{
gkvFresh.Update(bars[i], isNew: true);
}
double freshValue = gkvFresh.Last.Value;
Assert.Equal(freshValue, afterCorrections, 10);
}
// === Comparison with Theoretical Properties ===
/// <summary>
/// Validates GKV efficiency vs Parkinson (theoretical: GKV more efficient).
/// GKV uses 4 prices (OHLC), Parkinson uses 2 (HL).
/// Under certain conditions, GKV should be more stable.
/// </summary>
[Fact]
public void Gkv_Stability_ConsistentOverRepeatedRuns()
{
// Multiple runs with same seed should produce identical results
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 gkv = new Gkv(14);
for (int i = 0; i < bars.Count; i++)
{
gkv.Update(bars[i]);
}
results.Add(gkv.Last.Value);
}
// All runs should be identical
Assert.Equal(results[0], results[1], 15);
Assert.Equal(results[1], results[2], 15);
}
/// <summary>
/// Validates GKV responds to volatility regime changes.
/// </summary>
[Fact]
public void Gkv_RespondsToVolatilityRegimeChange()
{
var gkv = new Gkv(10);
// Low volatility regime
for (int i = 0; i < 20; i++)
{
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 101.0, 99.0, 100.0, 1000.0 // 2% range
);
gkv.Update(bar);
}
double lowVolValue = gkv.Last.Value;
// High volatility regime
for (int i = 20; i < 40; i++)
{
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 110.0, 90.0, 100.0, 1000.0 // 20% range
);
gkv.Update(bar);
}
double highVolValue = gkv.Last.Value;
Assert.True(highVolValue > lowVolValue * 2,
"GKV should significantly increase with higher volatility regime");
}
// === Helper Methods ===
private static double ComputeGkEstimator(double open, double high, double low, double close)
{
double lnH = Math.Log(high);
double lnL = Math.Log(low);
double lnO = Math.Log(open);
double lnC = Math.Log(close);
double term1 = 0.5 * Math.Pow(lnH - lnL, 2);
double coeff = 2.0 * Math.Log(2) - 1.0;
double term2 = coeff * Math.Pow(lnC - lnO, 2);
return term1 - term2;
}
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));
}
}