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
+327
View File
@@ -0,0 +1,327 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class HvIndicatorTests
{
[Fact]
public void HvIndicator_Constructor_SetsDefaults()
{
var indicator = new HvIndicator();
Assert.Equal(20, indicator.Period);
Assert.True(indicator.Annualize);
Assert.Equal(252, indicator.AnnualPeriods);
Assert.True(indicator.ShowColdValues);
Assert.Equal("HV - Historical Volatility (Close-to-Close)", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void HvIndicator_ShortName_IncludesParameters()
{
var indicator = new HvIndicator { Period = 14 };
Assert.Contains("HV", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("14", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void HvIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new HvIndicator();
Assert.Equal(0, HvIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void HvIndicator_Initialize_CreatesInternalHv()
{
var indicator = new HvIndicator();
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void HvIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new HvIndicator { Period = 10 };
indicator.Initialize();
// Add historical data with trending prices (needed for log returns)
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
double closePrice = 100 + i * 0.5 + Math.Sin(i * 0.3) * 2; // Trending with variation
indicator.HistoricalData.AddBar(now.AddMinutes(i), closePrice - 1, closePrice + 2, closePrice - 2, closePrice, 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 HvIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new HvIndicator { Period = 10 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
double closePrice = 100 + i * 0.3;
indicator.HistoricalData.AddBar(now.AddMinutes(i), closePrice - 1, closePrice + 2, closePrice - 2, closePrice, 1000);
}
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Add new bar with price jump
indicator.HistoricalData.AddBar(now.AddMinutes(30), 115, 120, 110, 118, 1500);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void HvIndicator_DifferentPeriods_Work()
{
int[] periods = { 5, 10, 14, 20 };
foreach (var period in periods)
{
var indicator = new HvIndicator { Period = period };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 50; i++)
{
double closePrice = 100 + i * 0.2 + Math.Sin(i * 0.5) * 3;
indicator.HistoricalData.AddBar(now.AddMinutes(i), closePrice - 1, closePrice + 2, closePrice - 2, closePrice, 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 HvIndicator_Period_CanBeChanged()
{
var indicator = new HvIndicator();
Assert.Equal(20, indicator.Period);
indicator.Period = 14;
Assert.Equal(14, indicator.Period);
indicator.Period = 10;
Assert.Equal(10, indicator.Period);
}
[Fact]
public void HvIndicator_Annualize_CanBeToggled()
{
var indicator = new HvIndicator();
Assert.True(indicator.Annualize);
indicator.Annualize = false;
Assert.False(indicator.Annualize);
indicator.Annualize = true;
Assert.True(indicator.Annualize);
}
[Fact]
public void HvIndicator_AnnualPeriods_CanBeChanged()
{
var indicator = new HvIndicator();
Assert.Equal(252, indicator.AnnualPeriods);
indicator.AnnualPeriods = 365;
Assert.Equal(365, indicator.AnnualPeriods);
indicator.AnnualPeriods = 52;
Assert.Equal(52, indicator.AnnualPeriods);
}
[Fact]
public void HvIndicator_ShowColdValues_CanBeToggled()
{
var indicator = new HvIndicator();
Assert.True(indicator.ShowColdValues);
indicator.ShowColdValues = false;
Assert.False(indicator.ShowColdValues);
indicator.ShowColdValues = true;
Assert.True(indicator.ShowColdValues);
}
[Fact]
public void HvIndicator_SourceCodeLink_IsValid()
{
var indicator = new HvIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Hv.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void HvIndicator_HighVolatility_ProducesHigherValue()
{
var indicator1 = new HvIndicator { Period = 10, Annualize = false };
var indicator2 = new HvIndicator { Period = 10, Annualize = false };
indicator1.Initialize();
indicator2.Initialize();
var now = DateTime.UtcNow;
// Indicator 1: low volatility (small price changes)
for (int i = 0; i < 30; i++)
{
double closePrice = 100 + i * 0.01; // Small consistent changes
indicator1.HistoricalData.AddBar(now.AddMinutes(i), closePrice - 0.5, closePrice + 0.5, closePrice - 0.5, closePrice, 1000);
indicator1.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
// Indicator 2: high volatility (large price swings)
for (int i = 0; i < 30; i++)
{
double closePrice = 100 + Math.Sin(i * 0.5) * 10; // Large swings
indicator2.HistoricalData.AddBar(now.AddMinutes(i), closePrice - 2, closePrice + 2, closePrice - 2, closePrice, 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 closes should produce higher HV value");
}
[Fact]
public void HvIndicator_AnnualizedValue_IsScaled()
{
var indicatorRaw = new HvIndicator { Period = 10, Annualize = false };
var indicatorAnn = new HvIndicator { Period = 10, Annualize = true, AnnualPeriods = 252 };
indicatorRaw.Initialize();
indicatorAnn.Initialize();
var now = DateTime.UtcNow;
// Same data for both - trending with variation
for (int i = 0; i < 30; i++)
{
double closePrice = 100 + i * 0.5 + Math.Sin(i * 0.3) * 2;
indicatorRaw.HistoricalData.AddBar(now.AddMinutes(i), closePrice - 1, closePrice + 2, closePrice - 2, closePrice, 1000);
indicatorRaw.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicatorAnn.HistoricalData.AddBar(now.AddMinutes(i), closePrice - 1, closePrice + 2, closePrice - 2, closePrice, 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 HvIndicator_OnlyUsesClose_IgnoresOpenHighLow()
{
// Test that HV only uses Close (not Open-High-Low)
var indicator1 = new HvIndicator { Period = 10, Annualize = false };
var indicator2 = new HvIndicator { Period = 10, Annualize = false };
indicator1.Initialize();
indicator2.Initialize();
var now = DateTime.UtcNow;
// Same close prices but different high/low
for (int i = 0; i < 30; i++)
{
double closePrice = 100 + i * 0.5;
// Indicator 1: narrow range
indicator1.HistoricalData.AddBar(now.AddMinutes(i), closePrice, closePrice + 1, closePrice - 1, closePrice, 1000);
indicator1.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Indicator 2: wide range (same close)
indicator2.HistoricalData.AddBar(now.AddMinutes(i), closePrice - 5, closePrice + 10, closePrice - 10, closePrice, 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));
// HV should be identical since close prices are the same
Assert.Equal(val1, val2, 10);
}
[Fact]
public void HvIndicator_ConstantPrice_ProducesZeroVolatility()
{
var indicator = new HvIndicator { Period = 10, Annualize = false };
indicator.Initialize();
var now = DateTime.UtcNow;
// Constant close price (no volatility in returns)
for (int i = 0; i < 30; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 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 close price should produce near-zero volatility");
}
[Fact]
public void HvIndicator_VaryingReturns_ProducesNonZeroVolatility()
{
var indicator = new HvIndicator { Period = 10, Annualize = false };
indicator.Initialize();
var now = DateTime.UtcNow;
// Price with varying returns (not constant growth rate) - should have non-zero volatility
// Alternating +2% and +0.5% returns to ensure variance in returns
for (int i = 0; i < 30; i++)
{
double rate = (i % 2 == 0) ? 1.02 : 1.005;
double closePrice = 100 * Math.Pow(rate, i / 2 + 1) * (i % 2 == 0 ? 1.0 : rate);
indicator.HistoricalData.AddBar(now.AddMinutes(i), closePrice - 1, closePrice + 1, closePrice - 1, closePrice, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val));
Assert.True(val > 0, "Varying returns should produce non-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 HvIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 2, 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 Hv _hv = null!;
private readonly LineSeries _series;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"HV {Period}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/volatility/hv/Hv.Quantower.cs";
public HvIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "HV - Historical Volatility (Close-to-Close)";
Description = "Historical Volatility measures price volatility using standard deviation of logarithmic returns, the classical close-to-close volatility estimator";
_series = new LineSeries(name: "HV", color: IndicatorExtensions.Volatility, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
protected override void OnInit()
{
_hv = new Hv(Period, Annualize, AnnualPeriods);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TBar bar = this.GetInputBar(args);
TValue result = _hv.Update(bar, isNew: args.IsNewBar());
_series.SetValue(result.Value, _hv.IsHot, ShowColdValues);
}
}
+737
View File
@@ -0,0 +1,737 @@
namespace QuanTAlib.Tests;
using Xunit;
public class HvTests
{
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));
}
private static TSeries GeneratePriceSeries(int count = 100)
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var t = new List<long>(count);
var v = new List<double>(count);
for (int i = 0; i < count; i++)
{
t.Add(bars[i].Time);
v.Add(bars[i].Close);
}
return new TSeries(t, v);
}
#region Constructor Tests
[Fact]
public void Constructor_DefaultParameters_SetsCorrectValues()
{
var hv = new Hv();
Assert.Equal(20, hv.Period);
Assert.True(hv.Annualize);
Assert.Equal(252, hv.AnnualPeriods);
Assert.Equal("Hv(20)", hv.Name);
Assert.Equal(21, hv.WarmupPeriod); // period + 1
}
[Fact]
public void Constructor_CustomParameters_SetsCorrectValues()
{
var hv = new Hv(period: 10, annualize: false, annualPeriods: 365);
Assert.Equal(10, hv.Period);
Assert.False(hv.Annualize);
Assert.Equal(365, hv.AnnualPeriods);
Assert.Equal("Hv(10)", hv.Name);
}
[Fact]
public void Constructor_PeriodOne_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Hv(period: 1));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_ZeroPeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Hv(period: 0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_NegativePeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Hv(period: -1));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_ZeroAnnualPeriodsWhenAnnualizing_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Hv(period: 10, annualize: true, annualPeriods: 0));
Assert.Equal("annualPeriods", ex.ParamName);
}
[Fact]
public void Constructor_ZeroAnnualPeriodsWhenNotAnnualizing_DoesNotThrow()
{
var hv = new Hv(period: 10, annualize: false, annualPeriods: 0);
Assert.Equal(0, hv.AnnualPeriods);
}
#endregion
#region Basic Calculation Tests
[Fact]
public void Update_SinglePrice_ReturnsZero()
{
var hv = new Hv(period: 5);
var price = new TValue(DateTime.UtcNow, 100.0);
var result = hv.Update(price);
// First price cannot produce a return, so volatility is 0
Assert.Equal(0.0, result.Value);
}
[Fact]
public void Update_TwoPrices_ReturnsZero()
{
var hv = new Hv(period: 5);
hv.Update(new TValue(DateTime.UtcNow, 100.0));
var result = hv.Update(new TValue(DateTime.UtcNow.AddMinutes(1), 101.0));
// Second price gives first return, but std dev of 1 value is 0
Assert.Equal(0.0, result.Value);
}
[Fact]
public void Update_MultiplePrices_ReturnsPositiveVolatility()
{
var hv = new Hv(period: 5);
var prices = GeneratePriceSeries(10);
double lastValue = 0;
for (int i = 0; i < prices.Count; i++)
{
lastValue = hv.Update(prices[i]).Value;
}
Assert.True(lastValue > 0, "HV should return positive volatility after warmup");
}
[Fact]
public void Update_ReturnsLastValue()
{
var hv = new Hv(period: 5);
var price = new TValue(DateTime.UtcNow, 100.0);
var result = hv.Update(price);
Assert.Equal(result.Value, hv.Last.Value, Tolerance);
}
[Fact]
public void Update_WithoutAnnualization_ReturnsSmallerValues()
{
var hvAnnual = new Hv(period: 10, annualize: true, annualPeriods: 252);
var hvNoAnnual = new Hv(period: 10, annualize: false);
var prices = GeneratePriceSeries(20);
double lastAnnual = 0;
double lastNoAnnual = 0;
for (int i = 0; i < prices.Count; i++)
{
lastAnnual = hvAnnual.Update(prices[i]).Value;
lastNoAnnual = hvNoAnnual.Update(prices[i]).Value;
}
// Annualized values should be larger by factor of sqrt(252)
Assert.True(lastAnnual > lastNoAnnual, "Annualized values should be larger");
}
[Fact]
public void Update_AnnualizationFactor_Correct()
{
var hvAnnual = new Hv(period: 10, annualize: true, annualPeriods: 252);
var hvNoAnnual = new Hv(period: 10, annualize: false);
var prices = GeneratePriceSeries(30);
for (int i = 0; i < prices.Count; i++)
{
hvAnnual.Update(prices[i]);
hvNoAnnual.Update(prices[i]);
}
double factor = hvAnnual.Last.Value / hvNoAnnual.Last.Value;
double expectedFactor = Math.Sqrt(252);
Assert.Equal(expectedFactor, factor, 1e-6);
}
#endregion
#region State Management Tests
[Fact]
public void Update_IsNewTrue_AdvancesState()
{
var hv = new Hv(period: 5);
var prices = GeneratePriceSeries(10);
// Feed enough prices to get non-zero volatility (need at least 3 returns for variance)
for (int i = 0; i < 5; i++)
{
hv.Update(prices[i], isNew: true);
}
var result1 = hv.Last.Value;
// Add one more price - state should advance
hv.Update(prices[5], isNew: true);
var result2 = hv.Last.Value;
// Both values should be positive (after warmup) and different
Assert.True(result1 > 0, "First result should be positive after warmup");
Assert.True(result2 > 0, "Second result should be positive");
Assert.NotEqual(result1, result2);
}
[Fact]
public void Update_IsNewFalse_UpdatesCurrentBar()
{
var hv = new Hv(period: 5);
var prices = GeneratePriceSeries(6);
// Process first 5 prices
for (int i = 0; i < 5; i++)
{
hv.Update(prices[i], isNew: true);
}
// Add 6th price
hv.Update(prices[5], isNew: true);
var firstValue = hv.Last.Value;
// Update the 6th price with different value
var updatedPrice = new TValue(prices[5].Time, prices[5].Value * 1.05);
hv.Update(updatedPrice, isNew: false);
var updatedValue = hv.Last.Value;
Assert.NotEqual(firstValue, updatedValue);
}
[Fact]
public void Update_IterativeCorrections_RestoresState()
{
var hv = new Hv(period: 5);
var prices = GeneratePriceSeries(10);
// Process first 5 prices
for (int i = 0; i < 5; i++)
{
hv.Update(prices[i], isNew: true);
}
// Add price 6 and correct multiple times
hv.Update(prices[5], isNew: true);
hv.Update(prices[5], isNew: false);
hv.Update(prices[5], isNew: false);
hv.Update(prices[5], isNew: false);
// Now continue with price 7
hv.Update(prices[6], isNew: true);
// Create new instance and process same data
var hv2 = new Hv(period: 5);
for (int i = 0; i < 7; i++)
{
hv2.Update(prices[i], isNew: true);
}
Assert.Equal(hv.Last.Value, hv2.Last.Value, Tolerance);
}
#endregion
#region IsHot and Warmup Tests
[Fact]
public void IsHot_BeforeWarmup_ReturnsFalse()
{
var hv = new Hv(period: 10);
var prices = GeneratePriceSeries(5);
for (int i = 0; i < prices.Count; i++)
{
hv.Update(prices[i]);
}
Assert.False(hv.IsHot);
}
[Fact]
public void IsHot_AfterWarmup_ReturnsTrue()
{
var hv = new Hv(period: 10);
var prices = GeneratePriceSeries(15);
for (int i = 0; i < prices.Count; i++)
{
hv.Update(prices[i]);
}
Assert.True(hv.IsHot);
}
[Fact]
public void IsHot_ExactlyAtWarmup_ReturnsTrue()
{
// Need period+1 prices to get period returns
var hv = new Hv(period: 10);
var prices = GeneratePriceSeries(11); // 11 prices = 10 returns
for (int i = 0; i < prices.Count; i++)
{
hv.Update(prices[i]);
}
Assert.True(hv.IsHot);
}
#endregion
#region Reset Tests
[Fact]
public void Reset_ClearsState()
{
var hv = new Hv(period: 5);
var prices = GeneratePriceSeries(10);
for (int i = 0; i < prices.Count; i++)
{
hv.Update(prices[i]);
}
hv.Reset();
Assert.False(hv.IsHot);
Assert.Equal(0, hv.Last.Value);
}
[Fact]
public void Reset_AllowsReprocessing()
{
var hv = new Hv(period: 5);
var prices = GeneratePriceSeries(10);
// First pass
for (int i = 0; i < prices.Count; i++)
{
hv.Update(prices[i]);
}
var firstResult = hv.Last.Value;
// Reset and second pass
hv.Reset();
for (int i = 0; i < prices.Count; i++)
{
hv.Update(prices[i]);
}
var secondResult = hv.Last.Value;
Assert.Equal(firstResult, secondResult, Tolerance);
}
#endregion
#region Robustness Tests
[Fact]
public void Update_WithNaNValues_UsesLastValidValue()
{
var hv = new Hv(period: 5);
var prices = GeneratePriceSeries(10);
for (int i = 0; i < prices.Count; i++)
{
hv.Update(prices[i]);
}
var valueBeforeInvalid = hv.Last.Value;
// Price with NaN - should use last valid value
var nanPrice = new TValue(DateTime.UtcNow, double.NaN);
var result = hv.Update(nanPrice);
Assert.True(double.IsFinite(result.Value), "Result should be finite when using last valid value");
Assert.Equal(valueBeforeInvalid, result.Value, Tolerance);
}
[Fact]
public void Update_WithInfinityValues_UsesLastValidValue()
{
var hv = new Hv(period: 5);
var prices = GeneratePriceSeries(10);
for (int i = 0; i < prices.Count; i++)
{
hv.Update(prices[i]);
}
var valueBeforeInvalid = hv.Last.Value;
// Price with infinity - should use last valid value
var infPrice = new TValue(DateTime.UtcNow, double.PositiveInfinity);
var result = hv.Update(infPrice);
Assert.True(double.IsFinite(result.Value), "Result should be finite when using last valid value");
Assert.Equal(valueBeforeInvalid, result.Value, Tolerance);
}
[Fact]
public void Update_WithZeroPrice_UsesLastValidValue()
{
var hv = new Hv(period: 5);
var prices = GeneratePriceSeries(10);
for (int i = 0; i < prices.Count; i++)
{
hv.Update(prices[i]);
}
var valueBeforeInvalid = hv.Last.Value;
// Zero price - invalid for log return
var zeroPrice = new TValue(DateTime.UtcNow, 0.0);
var result = hv.Update(zeroPrice);
Assert.True(double.IsFinite(result.Value), "Result should be finite when using last valid value");
Assert.Equal(valueBeforeInvalid, result.Value, Tolerance);
}
[Fact]
public void Update_WithNegativePrice_UsesLastValidValue()
{
var hv = new Hv(period: 5);
var prices = GeneratePriceSeries(10);
for (int i = 0; i < prices.Count; i++)
{
hv.Update(prices[i]);
}
var valueBeforeInvalid = hv.Last.Value;
// Negative price - invalid for log return
var negPrice = new TValue(DateTime.UtcNow, -100.0);
var result = hv.Update(negPrice);
Assert.True(double.IsFinite(result.Value), "Result should be finite when using last valid value");
Assert.Equal(valueBeforeInvalid, result.Value, Tolerance);
}
#endregion
#region Batch and Series Tests
[Fact]
public void Batch_MatchesStreamingResults()
{
const int dataCount = 100;
var prices = GeneratePriceSeries(dataCount);
// Streaming
var hvStreaming = new Hv(period: 10);
var streamingResults = new double[dataCount];
for (int i = 0; i < dataCount; i++)
{
streamingResults[i] = hvStreaming.Update(prices[i]).Value;
}
// Batch
var batchResults = new double[dataCount];
Hv.Batch(prices.Values, 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_TSeries_ReturnsCorrectLength()
{
const int dataCount = 50;
var priceSeries = GeneratePriceSeries(dataCount);
var result = Hv.Calculate(priceSeries, period: 10);
Assert.Equal(dataCount, result.Count);
}
[Fact]
public void Update_TSeries_MatchesStreamingResults()
{
const int dataCount = 50;
var priceSeries = GeneratePriceSeries(dataCount);
// Series update
var hvSeries = new Hv(period: 10);
var seriesResult = hvSeries.Update(priceSeries);
// Streaming
var hvStreaming = new Hv(period: 10);
var streamingResults = new double[dataCount];
for (int i = 0; i < dataCount; i++)
{
streamingResults[i] = hvStreaming.Update(priceSeries[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 prices = Array.Empty<double>();
var output = Array.Empty<double>();
// Should not throw
Hv.Batch(prices, output, period: 10);
Assert.Empty(output);
}
[Fact]
public void Batch_OutputTooShort_ThrowsArgumentException()
{
var prices = new double[10];
var output = new double[5]; // Too short
var ex = Assert.Throws<ArgumentException>(() =>
Hv.Batch(prices, output, period: 10));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Batch_InvalidPeriod_ThrowsArgumentException()
{
var prices = new double[10];
var output = new double[10];
var ex = Assert.Throws<ArgumentException>(() =>
Hv.Batch(prices, output, period: 1));
Assert.Equal("period", ex.ParamName);
}
#endregion
#region Event Publishing Tests
[Fact]
public void Update_PublishesEvent()
{
var hv = new Hv(period: 5);
bool eventFired = false;
hv.Pub += (object? sender, in TValueEventArgs args) => eventFired = true;
var price = new TValue(DateTime.UtcNow, 100.0);
hv.Update(price);
Assert.True(eventFired);
}
[Fact]
public void ChainedIndicator_ReceivesValues()
{
var source = new Hv(period: 5);
var downstream = new Sma(source, period: 3);
var prices = GeneratePriceSeries(15);
for (int i = 0; i < prices.Count; i++)
{
source.Update(prices[i]);
}
Assert.True(downstream.Last.Value > 0, "Downstream indicator should receive values");
}
#endregion
#region TBar Update Tests
[Fact]
public void Update_TBar_UsesClosePrice()
{
var hv1 = new Hv(period: 5);
var hv2 = new Hv(period: 5);
// Use TBar for hv1
var bar = new TBar(DateTime.UtcNow, 100.0, 105.0, 98.0, 102.0, 1000);
hv1.Update(bar);
// Use TValue with close price for hv2
var tvalue = new TValue(bar.Time, bar.Close);
hv2.Update(tvalue);
Assert.Equal(hv1.Last.Value, hv2.Last.Value, Tolerance);
}
[Fact]
public void Update_TBarSeries_ReturnsCorrectLength()
{
const int dataCount = 50;
var barSeries = GenerateTestData(dataCount);
var hv = new Hv(period: 10);
var result = hv.Update(barSeries);
Assert.Equal(dataCount, result.Count);
}
[Fact]
public void Hv_IgnoresHighLow_UsesOnlyClose()
{
// HV uses close prices only, so changing High-Low shouldn't affect result
var hv1 = new Hv(period: 5);
var hv2 = new Hv(period: 5);
// Bar with same Close but different High-Low
var bar1 = new TBar(DateTime.UtcNow, 100.0, 105.0, 98.0, 102.0, 1000);
var bar2 = new TBar(DateTime.UtcNow, 99.0, 200.0, 50.0, 102.0, 1000); // Different H-L, same Close
var result1 = hv1.Update(bar1).Value;
var result2 = hv2.Update(bar2).Value;
// Results should be identical since only Close matters
Assert.Equal(result1, result2, Tolerance);
}
#endregion
#region Additional Tests
[Fact]
public void LargeDataset_Performance()
{
var hv = new Hv(period: 20);
var prices = GeneratePriceSeries(5000);
for (int i = 0; i < prices.Count; i++)
{
var result = hv.Update(prices[i]);
Assert.True(double.IsFinite(result.Value));
}
}
[Fact]
public void DifferentParameters_ProduceDistinctValues()
{
var prices = GeneratePriceSeries(50);
var hv1 = new Hv(period: 10);
var hv2 = new Hv(period: 20);
var hv3 = new Hv(period: 10, annualize: false);
for (int i = 0; i < prices.Count; i++)
{
hv1.Update(prices[i]);
hv2.Update(prices[i]);
hv3.Update(prices[i]);
}
Assert.True(double.IsFinite(hv1.Last.Value));
Assert.True(double.IsFinite(hv2.Last.Value));
Assert.True(double.IsFinite(hv3.Last.Value));
// Different parameters should produce different values
Assert.NotEqual(hv1.Last.Value, hv2.Last.Value);
Assert.NotEqual(hv1.Last.Value, hv3.Last.Value);
}
[Fact]
public void StaticCalculate_TSeries_Works()
{
var prices = GeneratePriceSeries(100);
var result = Hv.Calculate(prices, period: 14);
Assert.Equal(100, result.Count);
Assert.True(double.IsFinite(result[result.Count - 1].Value));
}
[Fact]
public void StaticCalculate_TBarSeries_Works()
{
var bars = GenerateTestData(100);
var result = Hv.Calculate(bars, period: 14);
Assert.Equal(100, result.Count);
Assert.True(double.IsFinite(result[result.Count - 1].Value));
}
[Fact]
public void StaticCalculate_ValidatesInput()
{
var prices = GeneratePriceSeries(10);
Assert.Throws<ArgumentException>(() => Hv.Calculate(prices, period: 1));
Assert.Throws<ArgumentException>(() => Hv.Calculate(prices, period: 0));
Assert.Throws<ArgumentException>(() => Hv.Calculate(prices, period: -1));
Assert.Throws<ArgumentException>(() => Hv.Calculate(prices, period: 10, annualize: true, annualPeriods: 0));
}
[Fact]
public void Prime_Works()
{
var hv = new Hv(period: 5);
var values = new double[] { 100.0, 101.0, 99.5, 102.0, 100.5, 103.0, 101.0 };
hv.Prime(values);
Assert.True(hv.IsHot);
Assert.True(double.IsFinite(hv.Last.Value));
}
[Fact]
public void KnownValue_ManualCalculation()
{
// Test with known values to verify calculation
// Prices: 100, 102, 101, 103, 102 (5 prices = 4 returns)
// Log returns: ln(102/100), ln(101/102), ln(103/101), ln(102/103)
// = 0.01980263, -0.00985222, 0.01961015, -0.00975899
var hv = new Hv(period: 4, annualize: false);
var prices = new double[] { 100.0, 102.0, 101.0, 103.0, 102.0 };
for (int i = 0; i < prices.Length; i++)
{
hv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), prices[i]));
}
// Calculate expected population std dev manually
double[] returns = new double[4];
for (int i = 1; i < prices.Length; i++)
{
returns[i - 1] = Math.Log(prices[i] / prices[i - 1]);
}
double sum = 0, sumSq = 0;
for (int i = 0; i < returns.Length; i++)
{
sum += returns[i];
sumSq += returns[i] * returns[i];
}
double mean = sum / returns.Length;
double variance = (sumSq / returns.Length) - (mean * mean);
double expected = Math.Sqrt(variance);
Assert.Equal(expected, hv.Last.Value, 1e-9);
}
#endregion
}
+610
View File
@@ -0,0 +1,610 @@
namespace QuanTAlib.Test;
using Xunit;
/// <summary>
/// Validation tests for HV (Historical Volatility / Close-to-Close Volatility).
/// HV is the standard volatility estimator using log returns of closing prices.
/// Formula: σ = √(Var(log returns)) × √(annualPeriods)
/// Uses population variance over rolling window.
/// </summary>
public class HvValidationTests
{
private static TBarSeries GenerateTestData(int count = 100)
{
var gbm = new GBM(seed: 42);
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
}
private static TSeries GeneratePriceSeries(int count = 100)
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var t = new List<long>(count);
var v = new List<double>(count);
for (int i = 0; i < count; i++)
{
t.Add(bars[i].Time);
v.Add(bars[i].Close);
}
return new TSeries(t, v);
}
// === Mathematical Validation ===
/// <summary>
/// Validates log return formula: r_t = ln(price_t / price_{t-1})
/// </summary>
[Theory]
[InlineData(100.0, 101.0, 0.00995033)] // ~1% return
[InlineData(100.0, 110.0, 0.09531018)] // ~10% return
[InlineData(100.0, 90.0, -0.10536052)] // ~-10% return
[InlineData(100.0, 100.0, 0.0)] // no change
public void Hv_LogReturnFormula_IsCorrect(double prevPrice, double curPrice, double expectedReturn)
{
double logReturn = Math.Log(curPrice / prevPrice);
Assert.Equal(expectedReturn, logReturn, 6);
}
/// <summary>
/// Validates population variance formula: Var = E[X²] - E[X]²
/// </summary>
[Fact]
public void Hv_PopulationVarianceFormula_IsCorrect()
{
// Known values: 1, 2, 3, 4, 5
double[] values = { 1, 2, 3, 4, 5 };
double sum = 0, sumSq = 0;
for (int i = 0; i < values.Length; i++)
{
sum += values[i];
sumSq += values[i] * values[i];
}
double mean = sum / values.Length;
double variance = (sumSq / values.Length) - (mean * mean);
// Expected: mean = 3, E[X²] = (1+4+9+16+25)/5 = 11
// Var = 11 - 9 = 2
Assert.Equal(2.0, variance, 10);
}
/// <summary>
/// Validates standard deviation is square root of variance.
/// </summary>
[Fact]
public void Hv_StandardDeviationFormula_IsCorrect()
{
double variance = 4.0;
double stdDev = Math.Sqrt(variance);
Assert.Equal(2.0, stdDev, 10);
}
/// <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 Hv_AnnualizationFactor_IsCorrect(int annualPeriods, double expectedFactor)
{
double factor = Math.Sqrt(annualPeriods);
Assert.Equal(expectedFactor, factor, 10);
}
/// <summary>
/// Validates known volatility calculation.
/// </summary>
[Fact]
public void Hv_KnownCalculation_IsCorrect()
{
// Prices: 100, 102, 101, 103, 102 (5 prices = 4 returns)
double[] prices = { 100.0, 102.0, 101.0, 103.0, 102.0 };
double[] returns = new double[4];
for (int i = 1; i < prices.Length; i++)
{
returns[i - 1] = Math.Log(prices[i] / prices[i - 1]);
}
// Calculate population std dev
double sum = 0, sumSq = 0;
for (int i = 0; i < returns.Length; i++)
{
sum += returns[i];
sumSq += returns[i] * returns[i];
}
double mean = sum / returns.Length;
double variance = (sumSq / returns.Length) - (mean * mean);
double expected = Math.Sqrt(variance);
// Verify with indicator
var hv = new Hv(period: 4, annualize: false);
for (int i = 0; i < prices.Length; i++)
{
hv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), prices[i]));
}
Assert.Equal(expected, hv.Last.Value, 10);
}
/// <summary>
/// Validates that constant prices produce zero volatility.
/// </summary>
[Fact]
public void Hv_ConstantPrices_ProducesZeroVolatility()
{
var hv = new Hv(period: 10, annualize: false);
for (int i = 0; i < 20; i++)
{
hv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0));
}
// All returns are 0, so variance and std dev are 0
Assert.Equal(0.0, hv.Last.Value, 10);
}
/// <summary>
/// Validates rolling window properly removes old values.
/// </summary>
[Fact]
public void Hv_RollingWindow_RemovesOldValues()
{
var hv = new Hv(period: 5, annualize: false);
// First phase: volatile returns
double[] volatilePrices = { 100, 110, 90, 120, 80, 100 }; // 6 prices = 5 returns
for (int i = 0; i < volatilePrices.Length; i++)
{
hv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), volatilePrices[i]));
}
double highVolValue = hv.Last.Value;
// Second phase: constant prices (5 more)
for (int i = 6; i < 11; i++)
{
hv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0));
}
double afterConstantValue = hv.Last.Value;
// Rolling window should now only have zero returns
Assert.True(afterConstantValue < highVolValue, "Volatility should drop after constant prices");
Assert.Equal(0.0, afterConstantValue, 10);
}
// === Consistency Tests ===
/// <summary>
/// Validates streaming and batch produce identical results.
/// </summary>
[Fact]
public void Hv_StreamingMatchesBatch()
{
var prices = GeneratePriceSeries(100);
// Streaming calculation
var streamingHv = new Hv(14);
for (int i = 0; i < prices.Count; i++)
{
streamingHv.Update(prices[i]);
}
// Batch calculation
var batchResult = Hv.Calculate(prices, 14);
// Compare last values
Assert.Equal(batchResult.Last.Value, streamingHv.Last.Value, 8);
}
/// <summary>
/// Validates TSeries input matches TValue streaming.
/// </summary>
[Fact]
public void Hv_TSeriesInput_MatchesStreaming()
{
var prices = GeneratePriceSeries(100);
// Streaming
var streamingHv = new Hv(14);
for (int i = 0; i < prices.Count; i++)
{
streamingHv.Update(prices[i]);
}
// TSeries batch
var batchHv = new Hv(14);
var batchResult = batchHv.Update(prices);
Assert.Equal(batchResult.Last.Value, streamingHv.Last.Value, 10);
}
/// <summary>
/// Validates Span batch matches streaming.
/// </summary>
[Fact]
public void Hv_SpanBatch_MatchesStreaming()
{
var prices = GeneratePriceSeries(100);
// Streaming
var streamingHv = new Hv(14);
for (int i = 0; i < prices.Count; i++)
{
streamingHv.Update(prices[i]);
}
// Span batch
var output = new double[prices.Count];
Hv.Batch(prices.Values, output, 14);
Assert.Equal(output[^1], streamingHv.Last.Value, 10);
}
/// <summary>
/// Validates annualized output is scaled correctly.
/// </summary>
[Fact]
public void Hv_Annualized_ScaledCorrectly()
{
var prices = GeneratePriceSeries(50);
// Non-annualized
var hvRaw = new Hv(14, annualize: false);
// Annualized (default 252 periods)
var hvAnn = new Hv(14, annualize: true, annualPeriods: 252);
for (int i = 0; i < prices.Count; i++)
{
hvRaw.Update(prices[i]);
hvAnn.Update(prices[i]);
}
double expectedRatio = Math.Sqrt(252);
double actualRatio = hvAnn.Last.Value / hvRaw.Last.Value;
Assert.Equal(expectedRatio, actualRatio, 6);
}
/// <summary>
/// Validates TBar update uses only Close price.
/// </summary>
[Fact]
public void Hv_TBar_UsesOnlyClose()
{
var bars = GenerateTestData(50);
// Using TBar
var hvBar = new Hv(14);
for (int i = 0; i < bars.Count; i++)
{
hvBar.Update(bars[i]);
}
// Using just Close prices
var hvClose = new Hv(14);
for (int i = 0; i < bars.Count; i++)
{
hvClose.Update(new TValue(bars[i].Time, bars[i].Close));
}
Assert.Equal(hvClose.Last.Value, hvBar.Last.Value, 10);
}
// === Parameter Sensitivity ===
/// <summary>
/// Validates shorter period produces more responsive volatility.
/// </summary>
[Fact]
public void Hv_ShorterPeriod_MoreResponsive()
{
var prices = GeneratePriceSeries(50);
var hvShort = new Hv(5);
var hvLong = new Hv(20);
var shortResults = new List<double>();
var longResults = new List<double>();
for (int i = 0; i < prices.Count; i++)
{
hvShort.Update(prices[i]);
hvLong.Update(prices[i]);
if (hvShort.IsHot && hvLong.IsHot)
{
shortResults.Add(hvShort.Last.Value);
longResults.Add(hvLong.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 Hv_DifferentPeriods_ProduceDifferentResults()
{
var prices = GeneratePriceSeries(50);
var hv10 = new Hv(10);
var hv14 = new Hv(14);
var hv20 = new Hv(20);
for (int i = 0; i < prices.Count; i++)
{
hv10.Update(prices[i]);
hv14.Update(prices[i]);
hv20.Update(prices[i]);
}
Assert.NotEqual(hv10.Last.Value, hv14.Last.Value);
Assert.NotEqual(hv14.Last.Value, hv20.Last.Value);
}
// === Edge Cases ===
/// <summary>
/// Validates handling of very small price changes.
/// </summary>
[Fact]
public void Hv_VerySmallChanges_HandledCorrectly()
{
var hv = new Hv(14, annualize: false);
double price = 100.0;
for (int i = 0; i < 30; i++)
{
price += 0.001 * (i % 2 == 0 ? 1 : -1); // Tiny oscillation
hv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price));
}
Assert.True(double.IsFinite(hv.Last.Value));
Assert.True(hv.Last.Value >= 0, "Volatility should be non-negative");
Assert.True(hv.Last.Value < 0.01, "Small changes should produce small volatility");
}
/// <summary>
/// Validates handling of large price swings.
/// </summary>
[Fact]
public void Hv_LargePriceSwings_HandledCorrectly()
{
var hv = new Hv(14, annualize: false);
double price = 100.0;
for (int i = 0; i < 30; i++)
{
price *= (i % 2 == 0 ? 1.1 : 0.9); // 10% swings
hv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price));
}
Assert.True(double.IsFinite(hv.Last.Value));
Assert.True(hv.Last.Value > 0, "Large swings should produce positive volatility");
}
/// <summary>
/// Validates warmup period calculation (period + 1).
/// </summary>
[Theory]
[InlineData(10, 11)]
[InlineData(14, 15)]
[InlineData(20, 21)]
public void Hv_WarmupPeriod_IsPeriodPlusOne(int period, int expectedWarmup)
{
var hv = new Hv(period);
Assert.Equal(expectedWarmup, hv.WarmupPeriod);
}
/// <summary>
/// Validates output is always non-negative (volatility property).
/// </summary>
[Fact]
public void Hv_Output_IsNonNegative()
{
var prices = GeneratePriceSeries(100);
var hv = new Hv(14);
for (int i = 0; i < prices.Count; i++)
{
hv.Update(prices[i]);
if (hv.IsHot)
{
Assert.True(hv.Last.Value >= 0,
$"Volatility should be non-negative at bar {i}");
}
}
}
/// <summary>
/// Validates bar correction works correctly.
/// </summary>
[Fact]
public void Hv_BarCorrection_WorksCorrectly()
{
var hv = new Hv(14);
var prices = GeneratePriceSeries(30);
// Feed initial prices
for (int i = 0; i < 20; i++)
{
hv.Update(prices[i], isNew: true);
}
// Add new price
hv.Update(prices[20], isNew: true);
double afterNew = hv.Last.Value;
// Correct with very different price
var correctedPrice = new TValue(prices[20].Time, prices[20].Value * 2.0);
hv.Update(correctedPrice, isNew: false);
double afterCorrection = hv.Last.Value;
// Restore original
hv.Update(prices[20], isNew: false);
double afterRestore = hv.Last.Value;
Assert.NotEqual(afterNew, afterCorrection);
Assert.Equal(afterNew, afterRestore, 10);
}
/// <summary>
/// Validates iterative corrections converge to same result.
/// </summary>
[Fact]
public void Hv_IterativeCorrections_Converge()
{
var hv = new Hv(14);
var prices = GeneratePriceSeries(30);
// Feed prices and make corrections
for (int i = 0; i < 20; i++)
{
hv.Update(prices[i], isNew: true);
}
// Multiple corrections on same price
for (int j = 0; j < 5; j++)
{
var tempPrice = new TValue(prices[19].Time, prices[19].Value * (1.0 + j * 0.01));
hv.Update(tempPrice, isNew: false);
}
// Final correction back to original
hv.Update(prices[19], isNew: false);
double afterCorrections = hv.Last.Value;
// Fresh calculation
var hvFresh = new Hv(14);
for (int i = 0; i < 20; i++)
{
hvFresh.Update(prices[i], isNew: true);
}
double freshValue = hvFresh.Last.Value;
Assert.Equal(freshValue, afterCorrections, 10);
}
// === Comparison with Other Estimators ===
/// <summary>
/// Validates HV vs HLV: close-to-close vs high-low estimator.
/// </summary>
[Fact]
public void Hv_VsHlv_DifferentBehavior()
{
var bars = GenerateTestData(50);
var hv = new Hv(14, annualize: false);
var hlv = new Hlv(14, annualize: false);
for (int i = 0; i < bars.Count; i++)
{
hv.Update(bars[i]);
hlv.Update(bars[i]);
}
// Both should produce positive values
Assert.True(hv.Last.Value > 0);
Assert.True(hlv.Last.Value > 0);
// They should generally be different (HLV uses high-low range)
Assert.NotEqual(hv.Last.Value, hlv.Last.Value);
}
/// <summary>
/// Validates HV stability over repeated runs with same seed.
/// </summary>
[Fact]
public void Hv_Stability_ConsistentOverRepeatedRuns()
{
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 hv = new Hv(14);
for (int i = 0; i < bars.Count; i++)
{
hv.Update(bars[i]);
}
results.Add(hv.Last.Value);
}
Assert.Equal(results[0], results[1], 15);
Assert.Equal(results[1], results[2], 15);
}
/// <summary>
/// Validates HV responds to volatility regime changes.
/// </summary>
[Fact]
public void Hv_RespondsToVolatilityRegimeChange()
{
var hv = new Hv(10, annualize: false);
// Low volatility regime: small price changes
double price = 100.0;
for (int i = 0; i < 20; i++)
{
price *= (i % 2 == 0 ? 1.001 : 0.999); // 0.1% changes
hv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price));
}
double lowVolValue = hv.Last.Value;
// High volatility regime: large price changes
for (int i = 20; i < 40; i++)
{
price *= (i % 2 == 0 ? 1.05 : 0.95); // 5% changes
hv.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price));
}
double highVolValue = hv.Last.Value;
Assert.True(highVolValue > lowVolValue * 5,
"HV should significantly increase with higher volatility regime");
}
/// <summary>
/// Validates HV produces reasonable volatility estimate.
/// </summary>
[Fact]
public void Hv_ProducesReasonableVolatilityEstimate()
{
var prices = GeneratePriceSeries(100);
var hv = new Hv(14, annualize: false);
for (int i = 0; i < prices.Count; i++)
{
hv.Update(prices[i]);
}
Assert.True(double.IsFinite(hv.Last.Value));
Assert.True(hv.Last.Value > 0);
Assert.True(hv.Last.Value < 1, "Raw daily volatility should be < 100%");
}
// === Helper Methods ===
private static double Variance(List<double> values)
{
if (values.Count == 0)
{
return 0;
}
double mean = values.Average();
return values.Average(v => Math.Pow(v - mean, 2));
}
}
+495
View File
@@ -0,0 +1,495 @@
// Historical Volatility (HV) Indicator
// Close-to-close volatility using standard deviation of log returns
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// HV: Historical Volatility (Close-to-Close)
/// Calculates volatility as the standard deviation of log returns over a rolling window.
/// </summary>
/// <remarks>
/// <b>Calculation steps:</b>
/// <list type="number">
/// <item>Calculate log return: r_t = ln(price_t / price_{t-1})</item>
/// <item>Compute population standard deviation over period</item>
/// <item>If annualize: volatility × √(annualPeriods)</item>
/// </list>
///
/// <b>Key characteristics:</b>
/// <list type="bullet">
/// <item>Uses only closing prices (simplest volatility measure)</item>
/// <item>Rolling window standard deviation</item>
/// <item>Optional annualization (default 252 trading days)</item>
/// <item>Baseline for comparing other volatility estimators</item>
/// </list>
///
/// <b>Sources:</b>
/// Standard financial literature. Close-to-close volatility is the traditional
/// method taught in finance textbooks.
/// </remarks>
[SkipLocalsInit]
public sealed class Hv : AbstractBase
{
private const double Epsilon = 1e-10;
private readonly int _period;
private readonly bool _annualize;
private readonly int _annualPeriods;
private readonly double _annualFactor;
private readonly RingBuffer _buffer;
[StructLayout(LayoutKind.Auto)]
private record struct State(
double PrevPrice,
double Sum,
double SumSq,
double LastValidReturn,
double LastValue,
int FillCount
);
private State _s;
private State _ps;
/// <summary>
/// Initializes a new instance of the Hv class.
/// </summary>
/// <param name="period">The rolling window 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 2, or annualPeriods is less than 1 when annualizing.
/// </exception>
public Hv(int period = 20, bool annualize = true, int annualPeriods = 252)
{
if (period < 2)
{
throw new ArgumentException("Period must be at least 2", 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;
_annualFactor = annualize ? Math.Sqrt(annualPeriods) : 1.0;
_buffer = new RingBuffer(period);
WarmupPeriod = period + 1; // Need period+1 prices to get period returns
Name = $"Hv({period})";
_s = new State(double.NaN, 0, 0, 0, 0, 0);
_ps = _s;
}
/// <summary>
/// Initializes a new instance of the Hv class with a source.
/// </summary>
/// <param name="source">The data source for chaining.</param>
/// <param name="period">The rolling window 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 Hv(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.FillCount >= _period;
/// <summary>
/// The rolling window 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>
/// Updates the indicator with a new price value.
/// </summary>
/// <param name="input">The input price value.</param>
/// <param name="isNew">Whether this is a new bar or an update.</param>
/// <returns>The calculated volatility value.</returns>
[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 (uses Close price).
/// </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)
{
return UpdateCore(bar.Time, bar.Close, 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 close prices
Span<double> closes = len <= 128 ? stackalloc double[len] : new double[len];
for (int i = 0; i < len; i++)
{
closes[i] = source[i].Close;
tSpan[i] = source[i].Time;
}
Batch(closes, vSpan, _period, _annualize, _annualPeriods);
// Update internal state
for (int i = 0; i < len; i++)
{
Update(new TValue(source[i].Time, source[i].Close), 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);
Batch(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 price, bool isNew)
{
if (isNew)
{
_ps = _s;
_buffer.Snapshot();
}
else
{
_s = _ps;
_buffer.Restore();
}
var s = _s;
// Handle non-finite price
if (!double.IsFinite(price) || price <= 0)
{
// Can't compute return, output last value
Last = new TValue(timeTicks, s.LastValue);
PubEvent(Last, isNew);
return Last;
}
double volatility;
// First price - no return yet
if (double.IsNaN(s.PrevPrice))
{
s = s with { PrevPrice = price };
volatility = 0;
}
else
{
// Calculate log return
double logReturn = Math.Log(price / s.PrevPrice);
if (!double.IsFinite(logReturn))
{
logReturn = s.LastValidReturn;
}
else
{
s = s with { LastValidReturn = logReturn };
}
// Always use Add() after Snapshot/Restore pattern
// When isNew=false, Restore() reverts buffer to pre-Add state,
// so we need Add() (not UpdateNewest) to put the value back
_buffer.Add(logReturn);
// Recalculate sums from buffer - this ensures correctness after corrections
double sum = 0;
double sumSq = 0;
int fillCount = _buffer.Count;
for (int i = 0; i < fillCount; i++)
{
double r = _buffer[i];
sum += r;
sumSq += r * r;
}
// Calculate population variance: E[X²] - E[X]²
if (fillCount > 1)
{
double mean = sum / fillCount;
double variance = (sumSq / fillCount) - (mean * mean);
variance = Math.Max(0.0, variance); // Ensure non-negative
volatility = Math.Sqrt(variance) * _annualFactor;
}
else
{
volatility = 0;
}
s = s with
{
PrevPrice = price,
Sum = sum,
SumSq = sumSq,
FillCount = fillCount
};
}
if (!double.IsFinite(volatility))
{
volatility = s.LastValue;
}
else
{
s = s with { LastValue = volatility };
}
_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(double.NaN, 0, 0, 0, 0, 0);
_ps = _s;
_buffer.Clear();
Last = default;
}
/// <summary>
/// Calculates Historical Volatility for a price series (static).
/// </summary>
/// <param name="source">The source price series.</param>
/// <param name="period">The rolling window 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(TSeries source, int period = 20, bool annualize = true, int annualPeriods = 252)
{
if (period < 2)
{
throw new ArgumentException("Period must be at least 2", 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);
Batch(source.Values, vSpan, period, annualize, annualPeriods);
source.Times.CopyTo(tSpan);
return new TSeries(t, v);
}
/// <summary>
/// Calculates HV for a bar series (static).
/// </summary>
public static TSeries Calculate(TBarSeries source, int period = 20, bool annualize = true, int annualPeriods = 252)
{
var hv = new Hv(period, annualize, annualPeriods);
return hv.Update(source);
}
/// <summary>
/// Batch calculation using spans.
/// </summary>
/// <param name="prices">Price values.</param>
/// <param name="output">Output volatility values.</param>
/// <param name="period">The rolling window period.</param>
/// <param name="annualize">Whether to annualize.</param>
/// <param name="annualPeriods">Periods per year.</param>
public static void Batch(
ReadOnlySpan<double> prices,
Span<double> output,
int period = 20,
bool annualize = true,
int annualPeriods = 252)
{
if (period < 2)
{
throw new ArgumentException("Period must be at least 2", nameof(period));
}
if (annualize && annualPeriods <= 0)
{
throw new ArgumentException("Annual periods must be greater than 0 when annualizing", nameof(annualPeriods));
}
if (output.Length < prices.Length)
{
throw new ArgumentException("Output span must be at least as long as prices span", nameof(output));
}
int len = prices.Length;
if (len == 0)
{
return;
}
double annualFactor = annualize ? Math.Sqrt(annualPeriods) : 1.0;
// Use a ring buffer for log returns
Span<double> buffer = period <= 256 ? stackalloc double[period] : new double[period];
int head = 0;
int fillCount = 0;
double sum = 0;
double sumSq = 0;
double prevPrice = double.NaN;
double lastValidReturn = 0;
double lastValue = 0;
for (int i = 0; i < len; i++)
{
double price = prices[i];
// First price - no return
if (double.IsNaN(prevPrice))
{
prevPrice = price;
output[i] = 0;
continue;
}
// Handle invalid price
if (!double.IsFinite(price) || price <= 0)
{
output[i] = lastValue;
continue;
}
// Calculate log return
double logReturn = Math.Log(price / prevPrice);
prevPrice = price;
if (!double.IsFinite(logReturn))
{
logReturn = lastValidReturn;
}
else
{
lastValidReturn = logReturn;
}
// Remove oldest if buffer is full
if (fillCount == period)
{
double oldest = buffer[head];
sum -= oldest;
sumSq -= oldest * oldest;
}
else
{
fillCount++;
}
// Add new return
buffer[head] = logReturn;
head = (head + 1) % period;
sum += logReturn;
sumSq += logReturn * logReturn;
// Calculate volatility
double volatility;
if (fillCount > 1)
{
double mean = sum / fillCount;
double variance = (sumSq / fillCount) - (mean * mean);
variance = Math.Max(0.0, variance);
volatility = Math.Sqrt(variance) * annualFactor;
}
else
{
volatility = 0;
}
if (!double.IsFinite(volatility))
{
volatility = lastValue;
}
else
{
lastValue = volatility;
}
output[i] = volatility;
}
}
}
+293
View File
@@ -0,0 +1,293 @@
# HV: Historical Volatility (Close-to-Close)
> "The foundation of all volatility measures—simple, intuitive, and yet surprisingly informative when you understand what it's actually measuring."
Historical Volatility (HV), also known as close-to-close volatility or realized volatility, is the classical measure of price volatility using the standard deviation of logarithmic returns. First formalized in the early 20th century and central to the Black-Scholes option pricing model, HV remains the benchmark against which all other volatility estimators are compared. This implementation uses population standard deviation with a rolling window and optional annualization.
## Historical Context
The close-to-close volatility estimator predates most range-based alternatives, with its mathematical foundations established alongside the development of stochastic calculus and diffusion processes. The estimator became central to quantitative finance with the publication of the Black-Scholes model in 1973, which explicitly required an estimate of stock price volatility.
Louis Bachelier's 1900 thesis "Théorie de la spéculation" laid the groundwork, modeling price changes as Brownian motion. Fischer Black, Myron Scholes, and Robert Merton formalized the use of historical standard deviation of log returns as the volatility parameter in option pricing.
Despite the development of more efficient estimators (Parkinson 1980, Garman-Klass 1980, Yang-Zhang 2000), close-to-close volatility remains the most widely used and understood measure because:
1. It requires only closing prices, universally available
2. It directly measures what options traders care about—settlement-to-settlement variation
3. It serves as the baseline efficiency benchmark (efficiency = 1.0)
## Architecture & Physics
### 1. Log Return Calculation
Each period's return is computed as the natural logarithm of price ratios:
$$
r_t = \ln\left(\frac{P_t}{P_{t-1}}\right)
$$
where:
- $P_t$ = Closing price at time $t$
- $P_{t-1}$ = Closing price at time $t-1$
Log returns are preferred because they:
- Are time-additive: $r_{t_0 \to t_2} = r_{t_0 \to t_1} + r_{t_1 \to t_2}$
- Normalize percentage changes symmetrically around zero
- Cannot produce prices below zero when simulating
### 2. Rolling Window Statistics
The implementation maintains a rolling window of $n$ log returns and computes population variance using the computational formula:
$$
\sigma^2 = E[X^2] - E[X]^2 = \frac{\sum r_i^2}{n} - \left(\frac{\sum r_i}{n}\right)^2
$$
Two running sums are maintained:
- $\sum r_i$ — sum of returns
- $\sum r_i^2$ — sum of squared returns
This enables O(1) update complexity per new bar.
### 3. Population vs Sample Variance
This implementation uses **population variance** (dividing by $n$) rather than sample variance (dividing by $n-1$). For typical periods (14-30 returns), the difference is small:
| Period | Sample/Pop Ratio |
| :---: | :---: |
| 10 | 1.111 |
| 14 | 1.077 |
| 20 | 1.053 |
| 30 | 1.034 |
Population variance provides a consistent estimator for the rolling window and matches the implementation in most trading platforms.
### 4. Volatility Calculation
Convert variance to volatility (standard deviation):
$$
\sigma_t = \sqrt{variance}
$$
### 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
### Log Return Properties
For a geometric Brownian motion $dS = \mu S dt + \sigma S dW$:
The log return over interval $\Delta t$ is:
$$
r = \ln\left(\frac{S_t}{S_{t-1}}\right) = \left(\mu - \frac{\sigma^2}{2}\right)\Delta t + \sigma \sqrt{\Delta t} \cdot Z
$$
where $Z \sim N(0,1)$.
The variance of log returns is:
$$
\text{Var}(r) = \sigma^2 \Delta t
$$
Therefore, the annualized volatility is:
$$
\sigma_{annual} = \frac{\sigma_{period}}{\sqrt{\Delta t}} = \sigma_{period} \times \sqrt{N}
$$
### Efficiency Comparison
| Estimator | Relative Efficiency | Data Required |
| :--- | :---: | :--- |
| **Close-to-Close (HV)** | **1.0** | **C** |
| Parkinson (HLV) | 5.2 | H, L |
| Garman-Klass (GKV) | 7.4 | O, H, L, C |
| Rogers-Satchell | 8.4 | O, H, L, C |
| Yang-Zhang | 14.0 | O, H, L, C |
HV (close-to-close) is the efficiency baseline. A Parkinson estimator with efficiency 5.2 means you need 5.2× fewer observations to achieve the same precision—or equivalently, 5.2× better precision with the same observations.
### 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 |
### Warmup Period
HV requires `period + 1` prices to produce a valid result:
- First price establishes the baseline
- Next `period` prices generate `period` returns
- Standard deviation is calculated on these `period` returns
The `IsHot` property indicates when warmup is complete.
## Performance Profile
### Operation Count (Streaming Mode, Scalar)
Per-bar operations after warmup:
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| LOG | 1 | 25 | 25 |
| DIV | 1 | 15 | 15 |
| MUL | 2 | 3 | 6 |
| ADD/SUB | 4 | 1 | 4 |
| DIV (variance) | 2 | 15 | 30 |
| SQRT | 1 | 15 | 15 |
| MUL (annual) | 1 | 3 | 3 |
| **Total** | — | — | **~98 cycles** |
The dominant costs are LOG (26%) and SQRT (15%). Computational formula avoids iteration over the window.
### Batch Mode (512 values, SIMD/FMA)
| Operation | Scalar Ops | SIMD Ops (AVX2) | Speedup |
| :--- | :---: | :---: | :---: |
| LOG (vectorized) | 512 | 64 | 8× |
| DIV (prev price) | 512 | 64 | 8× |
| Rolling stats | 512 | 512 | 1× |
| SQRT (vectorized) | 512 | 64 | 8× |
**Note:** Rolling sum updates are sequential, limiting total batch improvement.
### Memory Profile
- **Per instance:** ~88 bytes (state struct + RingBuffer reference)
- **RingBuffer:** 8 bytes × period (default 20 = 160 bytes)
- **100 instances @ period 20:** ~24.8 KB
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 7/10 | Unbiased under GBM, but lowest efficiency |
| **Efficiency** | 5/10 | Baseline (1.0x), outperformed by range-based |
| **Timeliness** | 8/10 | Direct measurement, minimal lag |
| **Smoothness** | 6/10 | Can be noisy without smoothing |
| **Simplicity** | 10/10 | Only requires close prices |
## Validation
HV (close-to-close) is implemented in most technical analysis libraries:
| Library | Status | Notes |
| :--- | :---: | :--- |
| **TA-Lib** | N/A | Not directly implemented |
| **Skender** | N/A | Not directly implemented |
| **Tulip** | N/A | Not directly implemented |
| **OoplesFinance** | N/A | Not directly implemented |
| **PineScript** | ✅ | Matches hv.pine reference |
| **Manual** | ✅ | Validated against formula |
Note: Most libraries provide building blocks (STDDEV, LOG) rather than a dedicated HV function. The implementation is validated against the mathematical formula and PineScript reference.
## Common Pitfalls
1. **Warmup period**: HV requires `period + 1` prices before producing valid results. With default period=20, you need 21 prices to generate 20 returns. The `IsHot` property indicates when warmup is complete.
2. **Zero or negative prices**: Log transformation requires positive prices. Zero or negative values trigger last-valid-value substitution to prevent NaN propagation.
3. **Constant prices**: When all prices in the window are identical, returns are zero, producing zero volatility. This is mathematically correct but may indicate data issues.
4. **Annualization assumptions**: Default annualization assumes 252 trading days/year. For intraday data, cryptocurrency (365 days), or weekly data, adjust `annualPeriods` accordingly.
5. **Mean return assumption**: The standard formula implicitly subtracts the mean return. During strong trends, this captures both directional movement and noise, potentially overstating "noise" volatility.
6. **Population vs sample variance**: This implementation uses population variance (n divisor). If comparing with implementations using sample variance (n-1 divisor), expect slight differences: sample/pop ratio ≈ n/(n-1).
7. **Overnight gaps**: Unlike range-based estimators, HV fully captures overnight gaps (close-to-close movements). This can be an advantage (complete picture) or disadvantage (includes information not tradeable intraday).
8. **Comparison with range-based**: HV is 5.2× less efficient than Parkinson (HLV) and 7.4× less efficient than Garman-Klass (GKV). Use HV when:
- Only close prices are available
- You specifically want close-to-close volatility (e.g., settlement-based risk)
- Comparing with implied volatility (which prices close-to-close variation)
## Trading Applications
### Options Volatility Comparison
Compare realized HV with implied volatility (IV):
```
Volatility Risk Premium = IV - HV
If IV > HV consistently: Options are "expensive," consider selling
If IV < HV consistently: Options are "cheap," consider buying
```
This comparison is most valid with HV because IV prices close-to-close variation.
### Position Sizing
Use HV for volatility-adjusted position sizing:
```
Position size = Account risk / (HV × Price × √holding period)
```
Example: $100K account, 1% risk, HV = 0.25, Price = $100, 5-day hold:
Position = $1000 / (0.25 × $100 × √5) ≈ 17.9 shares
### Volatility Regime Detection
Track HV percentile over lookback:
```
High HV rank (>80%): High volatility regime
- Reduce position sizes
- Widen stop losses
- Consider volatility mean reversion trades
Low HV rank (<20%): Low volatility regime
- Potential for volatility expansion
- Breakout strategies may work better
- Options are likely cheap
```
### Historical Volatility Cones
Plot HV at multiple periods (10, 20, 60, 120 days) to see the term structure:
```
Normal: Short HV < Long HV (contango)
Inverted: Short HV > Long HV (backwardation, stress regime)
```
### Risk Reporting
HV is the standard for regulatory risk calculations (VaR, ES) because:
- Clear mathematical definition
- Universally understood
- Directly comparable across assets and time
## References
- Bachelier, L. (1900). "Théorie de la spéculation." *Annales scientifiques de l'École Normale Supérieure*, 17, 21-86.
- Black, F., & Scholes, M. (1973). "The Pricing of Options and Corporate Liabilities." *Journal of Political Economy*, 81(3), 637-654.
- Parkinson, M. (1980). "The Extreme Value Method for Estimating the Variance of the Rate of Return." *Journal of Business*, 53(1), 61-65.
- Garman, M. B., & Klass, M. J. (1980). "On the Estimation of Security Price Volatilities from Historical Data." *Journal of Business*, 53(1), 67-78.
- Merton, R. C. (1980). "On Estimating the Expected Return on the Market: An Exploratory Investigation." *Journal of Financial Economics*, 8(4), 323-361.