volatility indicators

This commit is contained in:
Miha Kralj
2026-02-01 17:48:16 -08:00
parent bcb52ef5ec
commit dde19f2226
40 changed files with 13350 additions and 62 deletions
+304
View File
@@ -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");
}
}
+55
View File
@@ -0,0 +1,55 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class GkvIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
public int Period { get; set; } = 20;
[InputParameter("Annualize", sortIndex: 2)]
public bool Annualize { get; set; } = true;
[InputParameter("Annual Periods", sortIndex: 3, 1, 365, 1, 0)]
public int AnnualPeriods { get; set; } = 252;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Gkv _gkv = null!;
private readonly LineSeries _series;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"GKV {Period}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/volatility/gkv/Gkv.Quantower.cs";
public GkvIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "GKV - Garman-Klass Volatility";
Description = "Garman-Klass Volatility is a range-based volatility estimator using OHLC data, providing more efficient estimates than close-to-close methods";
_series = new LineSeries(name: "GKV", color: IndicatorExtensions.Volatility, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
protected override void OnInit()
{
_gkv = new Gkv(Period, Annualize, AnnualPeriods);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TBar bar = this.GetInputBar(args);
TValue result = _gkv.Update(bar, isNew: args.IsNewBar());
_series.SetValue(result.Value, _gkv.IsHot, ShowColdValues);
}
}
+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.Calculate(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.Calculate(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.Calculate(bars, period: 0));
Assert.Throws<ArgumentException>(() => Gkv.Calculate(bars, period: -1));
Assert.Throws<ArgumentException>(() => Gkv.Calculate(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
}
+628
View File
@@ -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.Calculate(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));
}
}
+573
View File
@@ -0,0 +1,573 @@
// Garman-Klass Volatility (GKV) Indicator
// A range-based volatility estimator using OHLC data with RMA smoothing
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// GKV: Garman-Klass Volatility
/// A range-based volatility estimator that uses all four OHLC prices,
/// providing more efficient volatility estimates than close-to-close methods.
/// </summary>
/// <remarks>
/// <b>Calculation steps:</b>
/// <list type="number">
/// <item>Calculate log prices: lnH, lnL, lnO, lnC</item>
/// <item>term1 = 0.5 × (lnH - lnL)²</item>
/// <item>term2 = (2×ln(2) - 1) × (lnC - lnO)²</item>
/// <item>gkEstimator = term1 - term2</item>
/// <item>Smooth using bias-corrected RMA</item>
/// <item>volatility = √(smoothedEstimator)</item>
/// <item>If annualize: volatility × √(annualPeriods)</item>
/// </list>
///
/// <b>Key characteristics:</b>
/// <list type="bullet">
/// <item>Uses OHLC data for more efficient estimation</item>
/// <item>RMA (Wilder's) smoothing with bias correction</item>
/// <item>Optional annualization (default 252 trading days)</item>
/// <item>More efficient than close-to-close estimators</item>
/// </list>
///
/// <b>Sources:</b>
/// Mark B. Garman and Michael J. Klass (1980). "On the Estimation of Security Price
/// Volatilities from Historical Data." Journal of Business, 53(1), 67-78.
/// </remarks>
[SkipLocalsInit]
public sealed class Gkv : AbstractBase
{
private const double C_2LN2_1 = 0.38629436111989061883; // 2 * ln(2) - 1
private const double Epsilon = 1e-10;
private readonly int _period;
private readonly bool _annualize;
private readonly int _annualPeriods;
private readonly double _alpha;
private readonly double _decay;
private readonly double _annualFactor;
[StructLayout(LayoutKind.Auto)]
private record struct State(
double RawRma,
double E,
double LastValidGk,
double LastValue,
int Count
);
private State _s;
private State _ps;
/// <summary>
/// Initializes a new instance of the Gkv class.
/// </summary>
/// <param name="period">The smoothing period (default 20).</param>
/// <param name="annualize">Whether to annualize the volatility (default true).</param>
/// <param name="annualPeriods">Number of periods per year (default 252).</param>
/// <exception cref="ArgumentException">
/// Thrown when period is less than 1, or annualPeriods is less than 1 when annualizing.
/// </exception>
public Gkv(int period = 20, bool annualize = true, int annualPeriods = 252)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
if (annualize && annualPeriods <= 0)
{
throw new ArgumentException("Annual periods must be greater than 0 when annualizing", nameof(annualPeriods));
}
_period = period;
_annualize = annualize;
_annualPeriods = annualPeriods;
_alpha = 1.0 / period;
_decay = 1.0 - _alpha;
_annualFactor = annualize ? Math.Sqrt(annualPeriods) : 1.0;
WarmupPeriod = period;
Name = $"Gkv({period})";
_s = new State(0, 1.0, 0, 0, 0);
_ps = _s;
}
/// <summary>
/// Initializes a new instance of the Gkv class with a source.
/// </summary>
/// <param name="source">The data source for chaining.</param>
/// <param name="period">The smoothing period (default 20).</param>
/// <param name="annualize">Whether to annualize the volatility (default true).</param>
/// <param name="annualPeriods">Number of periods per year (default 252).</param>
public Gkv(ITValuePublisher source, int period = 20, bool annualize = true, int annualPeriods = 252)
: this(period, annualize, annualPeriods)
{
source.Pub += Handle;
}
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
/// <summary>
/// True if the indicator has enough data for valid results.
/// </summary>
public override bool IsHot => _s.Count >= WarmupPeriod;
/// <summary>
/// The smoothing period.
/// </summary>
public int Period => _period;
/// <summary>
/// Whether volatility is annualized.
/// </summary>
public bool Annualize => _annualize;
/// <summary>
/// Number of periods per year for annualization.
/// </summary>
public int AnnualPeriods => _annualPeriods;
/// <summary>
/// Computes the Garman-Klass estimator for a single bar.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
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 hlRange = lnH - lnL;
double coRange = lnC - lnO;
// term1 = 0.5 * (lnH - lnL)^2
// term2 = (2*ln(2) - 1) * (lnC - lnO)^2
// gkEstimator = term1 - term2
double term1 = 0.5 * hlRange * hlRange;
double term2 = C_2LN2_1 * coRange * coRange;
return term1 - term2;
}
/// <summary>
/// Updates the indicator with a TValue input.
/// For GKV, this treats the value as a pre-computed GK estimator.
/// Prefer Update(TBar) for standard OHLC data.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
return UpdateCore(input.Time, input.Value, isNew);
}
/// <summary>
/// Updates the indicator with a new bar (preferred method).
/// </summary>
/// <param name="bar">The input bar.</param>
/// <param name="isNew">Whether this is a new bar or an update.</param>
/// <returns>The calculated volatility value.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar bar, bool isNew = true)
{
// Handle invalid OHLC data
if (!double.IsFinite(bar.Open) || !double.IsFinite(bar.High) ||
!double.IsFinite(bar.Low) || !double.IsFinite(bar.Close) ||
bar.Open <= 0 || bar.High <= 0 || bar.Low <= 0 || bar.Close <= 0)
{
// Pass NaN to trigger last-valid-value substitution
return UpdateCore(bar.Time, double.NaN, isNew);
}
double gkEstimator = ComputeGkEstimator(bar.Open, bar.High, bar.Low, bar.Close);
return UpdateCore(bar.Time, gkEstimator, isNew);
}
/// <summary>
/// Updates the indicator with a bar series.
/// </summary>
/// <param name="source">The source bar series.</param>
/// <returns>A TSeries containing the volatility values.</returns>
public TSeries Update(TBarSeries source)
{
if (source.Count == 0)
{
return [];
}
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
// Extract OHLC data
Span<double> opens = len <= 64 ? stackalloc double[len] : new double[len];
Span<double> highs = len <= 64 ? stackalloc double[len] : new double[len];
Span<double> lows = len <= 64 ? stackalloc double[len] : new double[len];
Span<double> closes = len <= 64 ? stackalloc double[len] : new double[len];
for (int i = 0; i < len; i++)
{
opens[i] = source[i].Open;
highs[i] = source[i].High;
lows[i] = source[i].Low;
closes[i] = source[i].Close;
tSpan[i] = source[i].Time;
}
Batch(opens, highs, lows, closes, vSpan, _period, _annualize, _annualPeriods);
// Update internal state
for (int i = 0; i < len; i++)
{
Update(source[i], isNew: true);
}
return new TSeries(t, v);
}
/// <inheritdoc/>
public override TSeries Update(TSeries source)
{
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
// Treat source values as pre-computed GK estimators
BatchFromEstimators(source.Values, vSpan, _period, _annualize, _annualPeriods);
source.Times.CopyTo(tSpan);
// Update internal state
for (int i = 0; i < len; i++)
{
Update(new TValue(source.Times[i], source.Values[i]), isNew: true);
}
return new TSeries(t, v);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private TValue UpdateCore(long timeTicks, double gkEstimator, bool isNew)
{
if (isNew)
{
_ps = _s;
}
else
{
_s = _ps;
}
var s = _s;
// Handle non-finite estimator - use last valid value
if (!double.IsFinite(gkEstimator))
{
gkEstimator = s.LastValidGk;
}
else
{
s.LastValidGk = gkEstimator;
}
// RMA smoothing with bias correction
double rawRma, e;
if (s.Count == 0)
{
rawRma = gkEstimator;
e = _decay;
}
else
{
// RMA: raw_rma = prev_rma * decay + alpha * value
rawRma = Math.FusedMultiplyAdd(s.RawRma, _decay, _alpha * gkEstimator);
e = _decay * s.E;
}
// Bias correction
double correctedRma = e > Epsilon ? rawRma / (1.0 - e) : rawRma;
// Calculate volatility
double volatility;
if (correctedRma < 0)
{
volatility = 0; // Can't take sqrt of negative
}
else
{
volatility = Math.Sqrt(correctedRma) * _annualFactor;
}
if (!double.IsFinite(volatility))
{
volatility = s.LastValue;
}
// Update state using direct field assignment (like Cvi pattern)
s.RawRma = rawRma;
s.E = e;
s.LastValue = volatility;
if (isNew)
{
s.Count++;
}
_s = s;
Last = new TValue(timeTicks, volatility);
PubEvent(Last, isNew);
return Last;
}
/// <inheritdoc/>
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
for (int i = 0; i < source.Length; i++)
{
Update(new TValue(DateTime.UtcNow, source[i]), isNew: true);
}
}
/// <inheritdoc/>
public override void Reset()
{
_s = new State(0, 1.0, 0, 0, 0);
_ps = _s;
Last = default;
}
/// <summary>
/// Calculates Garman-Klass Volatility for a bar series (static).
/// </summary>
/// <param name="source">The source bar series.</param>
/// <param name="period">The smoothing period.</param>
/// <param name="annualize">Whether to annualize.</param>
/// <param name="annualPeriods">Periods per year.</param>
/// <returns>A TSeries containing the volatility values.</returns>
public static TSeries Calculate(TBarSeries source, int period = 20, bool annualize = true, int annualPeriods = 252)
{
var gkv = new Gkv(period, annualize, annualPeriods);
return gkv.Update(source);
}
/// <summary>
/// Calculates GKV for a TSeries (treats values as pre-computed GK estimators).
/// </summary>
public static TSeries Calculate(TSeries source, int period = 20, bool annualize = true, int annualPeriods = 252)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
if (annualize && annualPeriods <= 0)
{
throw new ArgumentException("Annual periods must be greater than 0 when annualizing", nameof(annualPeriods));
}
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
BatchFromEstimators(source.Values, vSpan, period, annualize, annualPeriods);
source.Times.CopyTo(tSpan);
return new TSeries(t, v);
}
/// <summary>
/// Batch calculation using spans for OHLC data.
/// </summary>
/// <param name="open">Open prices.</param>
/// <param name="high">High prices.</param>
/// <param name="low">Low prices.</param>
/// <param name="close">Close prices.</param>
/// <param name="output">Output volatility values.</param>
/// <param name="period">The smoothing period.</param>
/// <param name="annualize">Whether to annualize.</param>
/// <param name="annualPeriods">Periods per year.</param>
public static void Batch(
ReadOnlySpan<double> open,
ReadOnlySpan<double> high,
ReadOnlySpan<double> low,
ReadOnlySpan<double> close,
Span<double> output,
int period = 20,
bool annualize = true,
int annualPeriods = 252)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
if (annualize && annualPeriods <= 0)
{
throw new ArgumentException("Annual periods must be greater than 0 when annualizing", nameof(annualPeriods));
}
int len = open.Length;
if (high.Length != len || low.Length != len || close.Length != len)
{
throw new ArgumentException("All input spans must have the same length", nameof(high));
}
if (output.Length < len)
{
throw new ArgumentException("Output span must be at least as long as input spans", nameof(output));
}
if (len == 0)
{
return;
}
double alpha = 1.0 / period;
double decay = 1.0 - alpha;
double annualFactor = annualize ? Math.Sqrt(annualPeriods) : 1.0;
double rawRma = 0;
double e = 1.0;
double lastValidGk = 0;
double lastValue = 0;
for (int i = 0; i < len; i++)
{
double o = open[i];
double h = high[i];
double l = low[i];
double c = close[i];
double gkEstimator;
// Handle invalid data
if (!double.IsFinite(o) || !double.IsFinite(h) ||
!double.IsFinite(l) || !double.IsFinite(c) ||
o <= 0 || h <= 0 || l <= 0 || c <= 0)
{
gkEstimator = lastValidGk;
}
else
{
gkEstimator = ComputeGkEstimator(o, h, l, c);
if (!double.IsFinite(gkEstimator))
{
gkEstimator = lastValidGk;
}
else
{
lastValidGk = gkEstimator;
}
}
if (i == 0)
{
rawRma = gkEstimator;
e = decay;
}
else
{
rawRma = Math.FusedMultiplyAdd(rawRma, decay, alpha * gkEstimator);
e *= decay;
}
double correctedRma = e > Epsilon ? rawRma / (1.0 - e) : rawRma;
double volatility = correctedRma < 0 ? 0 : Math.Sqrt(correctedRma) * annualFactor;
if (!double.IsFinite(volatility))
{
volatility = lastValue;
}
else
{
lastValue = volatility;
}
output[i] = volatility;
}
}
/// <summary>
/// Batch calculation from pre-computed GK estimators.
/// </summary>
private static void BatchFromEstimators(
ReadOnlySpan<double> estimators,
Span<double> output,
int period,
bool annualize,
int annualPeriods)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
if (estimators.Length != output.Length)
{
throw new ArgumentException("Source and output must have the same length", nameof(output));
}
int len = estimators.Length;
if (len == 0)
{
return;
}
double alpha = 1.0 / period;
double decay = 1.0 - alpha;
double annualFactor = annualize ? Math.Sqrt(annualPeriods) : 1.0;
double rawRma = 0;
double e = 1.0;
double lastValidGk = 0;
double lastValue = 0;
for (int i = 0; i < len; i++)
{
double gkEstimator = estimators[i];
if (!double.IsFinite(gkEstimator))
{
gkEstimator = lastValidGk;
}
else
{
lastValidGk = gkEstimator;
}
if (i == 0)
{
rawRma = gkEstimator;
e = decay;
}
else
{
rawRma = Math.FusedMultiplyAdd(rawRma, decay, alpha * gkEstimator);
e *= decay;
}
double correctedRma = e > Epsilon ? rawRma / (1.0 - e) : rawRma;
double volatility = correctedRma < 0 ? 0 : Math.Sqrt(correctedRma) * annualFactor;
if (!double.IsFinite(volatility))
{
volatility = lastValue;
}
else
{
lastValue = volatility;
}
output[i] = volatility;
}
}
}
+279
View File
@@ -0,0 +1,279 @@
# GKV: Garman-Klass Volatility
> "Why settle for closing prices when you have the full trading range? It's like judging a book by its last page."
Garman-Klass Volatility (GKV) is a range-based volatility estimator that uses all four OHLC prices to provide more efficient volatility estimates than traditional close-to-close methods. Developed by Mark Garman and Michael Klass in 1980, this estimator achieves theoretical efficiency gains of 7-8x over simple close-to-close variance by incorporating intraday price information. The implementation includes RMA (Wilder's) smoothing with bias correction and optional annualization.
## Historical Context
Mark B. Garman and Michael J. Klass introduced this estimator in their 1980 paper "On the Estimation of Security Price Volatilities from Historical Data," published in the Journal of Business. Their work addressed a fundamental inefficiency: close-to-close volatility estimators discard valuable intraday price information.
The Garman-Klass estimator derives from the theory of diffusion processes, assuming prices follow geometric Brownian motion. The key insight is that the high-low range contains significant information about volatility that close-to-close methods ignore. By weighting the log-range and open-close components appropriately, the GK estimator achieves near-optimal efficiency under the assumption of continuous trading with no drift.
The famous coefficient $2\ln(2) - 1 \approx 0.386$ emerges from the mathematical derivation as the optimal weight for the open-close component. This specific value minimizes the variance of the estimator under the Brownian motion assumption.
## Architecture & Physics
### 1. Log Price Transformation
All calculations use log prices to normalize percentage returns:
$$
\ln H_t, \ln L_t, \ln O_t, \ln C_t
$$
where:
- $H_t, L_t, O_t, C_t$ = High, Low, Open, Close prices at time $t$
Log transformation ensures that equal percentage moves have equal magnitude regardless of price level.
### 2. Garman-Klass Estimator
The single-period GK variance estimator combines two terms:
$$
\hat{\sigma}^2_{GK,t} = 0.5 \cdot (\ln H_t - \ln L_t)^2 - (2\ln 2 - 1) \cdot (\ln C_t - \ln O_t)^2
$$
Equivalently:
$$
\hat{\sigma}^2_{GK,t} = \underbrace{0.5 \cdot r_{HL}^2}_{\text{term1}} - \underbrace{0.386 \cdot r_{CO}^2}_{\text{term2}}
$$
where:
- $r_{HL} = \ln H_t - \ln L_t$ (log high-low range)
- $r_{CO} = \ln C_t - \ln O_t$ (log close-open return)
- $2\ln 2 - 1 \approx 0.38629436$ (Garman-Klass coefficient)
### 3. RMA Smoothing with Bias Correction
The raw estimator is smoothed using an RMA (Wilder's Moving Average):
$$
RMA_t^{raw} = RMA_{t-1}^{raw} \cdot (1 - \alpha) + \alpha \cdot \hat{\sigma}^2_{GK,t}
$$
where:
- $\alpha = 1 / period$
- Default $period = 20$
Bias correction compensates for the exponential startup:
$$
e_t = (1 - \alpha)^t
$$
$$
RMA_t^{corrected} = \frac{RMA_t^{raw}}{1 - e_t}
$$
### 4. Volatility Calculation
Convert variance to volatility (standard deviation):
$$
\sigma_t = \sqrt{RMA_t^{corrected}}
$$
### 5. Optional Annualization
If annualization is enabled (default):
$$
\sigma_{annual,t} = \sigma_t \times \sqrt{N}
$$
where $N$ = annual periods (default 252 trading days).
## Mathematical Foundation
### Garman-Klass Coefficient Derivation
The coefficient $2\ln 2 - 1$ arises from minimizing the variance of the estimator under Brownian motion assumptions. For a diffusion process $dS = \sigma S dW$:
$$
E[(\ln H - \ln L)^2] = 4 \ln 2 \cdot \sigma^2 \cdot \Delta t
$$
$$
E[(\ln C - \ln O)^2] = \sigma^2 \cdot \Delta t
$$
The optimal combination that minimizes estimator variance yields the coefficient:
$$
c = 2\ln 2 - 1 \approx 0.38629436
$$
### Efficiency Comparison
| Estimator | Relative Efficiency |
| :--- | :---: |
| Close-to-Close | 1.0 |
| Parkinson (High-Low) | 5.2 |
| Garman-Klass (OHLC) | 7.4 |
| Rogers-Satchell | 8.4 |
| Yang-Zhang | 14.0 |
GKV achieves 7.4x the efficiency of close-to-close, meaning it produces the same statistical precision with 7.4x fewer observations.
### RMA Properties
**Smoothing Factor:**
$$
\alpha = \frac{1}{period}
$$
| Period | α | Half-life (bars) |
| :---: | :---: | :---: |
| 10 | 0.100 | 6.6 |
| 14 | 0.071 | 9.4 |
| 20 | 0.050 | 13.5 |
| 30 | 0.033 | 20.5 |
RMA (Wilder's) is more responsive than SMA but slower than EMA with equivalent period.
### Annualization Factor
For daily data with 252 trading days:
$$
\sqrt{252} \approx 15.875
$$
Common annualization factors:
| Data Frequency | Periods/Year | Factor |
| :--- | :---: | :---: |
| Daily | 252 | 15.875 |
| Weekly | 52 | 7.211 |
| Monthly | 12 | 3.464 |
| Hourly (6.5h/day) | 1638 | 40.472 |
## Performance Profile
### Operation Count (Streaming Mode, Scalar)
Per-bar operations after warmup:
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| LOG | 4 | 25 | 100 |
| SUB | 3 | 1 | 3 |
| MUL | 3 | 3 | 9 |
| FMA (RMA) | 1 | 4 | 4 |
| DIV (bias) | 1 | 15 | 15 |
| SQRT | 1 | 15 | 15 |
| MUL (annual) | 1 | 3 | 3 |
| **Total** | — | — | **~149 cycles** |
The dominant cost is the four LOG operations (67% of total).
### Batch Mode (512 values, SIMD/FMA)
| Operation | Scalar Ops | SIMD Ops (AVX2) | Speedup |
| :--- | :---: | :---: | :---: |
| LOG (vectorized) | 2048 | 256 | 8× |
| Range calculations | 1536 | 192 | 8× |
| RMA (sequential) | 512 | 512 | 1× |
| SQRT (vectorized) | 512 | 64 | 8× |
**Note:** RMA smoothing is inherently sequential, limiting total batch improvement. LOG operations benefit most from SIMD vectorization.
### Memory Profile
- **Per instance:** ~72 bytes (state struct)
- **No ring buffer required** (RMA is recursive)
- **100 instances:** ~7.2 KB
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 9/10 | Theoretically optimal under Brownian motion |
| **Efficiency** | 9/10 | 7.4x better than close-to-close |
| **Timeliness** | 7/10 | RMA introduces smoothing lag |
| **Smoothness** | 8/10 | RMA provides stable output |
| **Robustness** | 7/10 | Sensitive to gaps and overnight moves |
## Validation
GKV is well-documented in academic literature but less common in technical analysis libraries:
| Library | Status | Notes |
| :--- | :---: | :--- |
| **TA-Lib** | N/A | Not implemented |
| **Skender** | N/A | Not implemented |
| **Tulip** | N/A | Not implemented |
| **OoplesFinance** | N/A | Not implemented |
| **PineScript** | ✅ | Matches gkv.pine reference |
| **Manual** | ✅ | Validated against formula |
The implementation is validated against the original Garman-Klass 1980 paper formula.
## Common Pitfalls
1. **Warmup period**: GKV requires $period$ bars before producing stable results. With default period=20, the first 19 values are warming up. The `IsHot` property indicates when warmup is complete.
2. **Negative estimator values**: The GK estimator can produce negative values when the close-open range dominates the high-low range (e.g., gap days with narrow trading range). The implementation handles this by returning zero volatility for negative variance.
3. **Invalid OHLC data**: Prices must be positive for log transformation. Zero or negative prices, or logically invalid OHLC (high < low, etc.) trigger last-valid-value substitution.
4. **Annualization assumption**: Default annualization assumes 252 trading days/year. For other frequencies (hourly, weekly), adjust the `annualPeriods` parameter accordingly.
5. **Drift assumption**: The GK estimator assumes zero drift (no trend). During strong trends, the estimator may underestimate volatility. Consider Rogers-Satchell or Yang-Zhang for trending markets.
6. **Gap sensitivity**: Unlike close-to-close methods, GKV doesn't directly capture overnight gaps. A stock that gaps up 5% then trades in a narrow range will show low GKV despite the gap.
7. **Not a trading signal**: GKV measures volatility magnitude, not direction. High volatility can precede moves in either direction; use with directional indicators for trading signals.
## Trading Applications
### Position Sizing
Use GKV to scale position sizes inversely with volatility:
```
Position size = Risk per trade / (GKV × Price × ATR multiplier)
```
Lower GKV allows larger positions; higher GKV requires smaller positions.
### Options Pricing Input
GKV provides a realized volatility estimate for comparison with implied volatility:
```
If IV > GKV significantly: Options may be overpriced (sell vol)
If IV < GKV significantly: Options may be underpriced (buy vol)
```
### Regime Detection
Track GKV percentile rank over lookback period:
```
High rank (>80%): High volatility regime — reduce position size, widen stops
Low rank (<20%): Low volatility regime — potential for breakout
```
### Volatility Breakout Filter
Combine GKV with rate-of-change:
```
Signal: GKV crosses above 20-bar high → volatility expansion
Confirmation: Wait for directional move
```
## References
- Garman, M. B., & Klass, M. J. (1980). "On the Estimation of Security Price Volatilities from Historical Data." *Journal of Business*, 53(1), 67-78.
- Parkinson, M. (1980). "The Extreme Value Method for Estimating the Variance of the Rate of Return." *Journal of Business*, 53(1), 61-65.
- Rogers, L. C. G., & Satchell, S. E. (1991). "Estimating Variance from High, Low and Closing Prices." *Annals of Applied Probability*, 1(4), 504-512.
- Yang, D., & Zhang, Q. (2000). "Drift-Independent Volatility Estimation Based on High, Low, Open, and Close Prices." *Journal of Business*, 73(3), 477-491.