mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-24 13:38:05 +00:00
docs: remove C# Implementation Considerations sections, clean up temp scripts, reorganize test files
- Remove 'C# Implementation Considerations' sections from 34 indicator .md files - Delete 29 temp PowerShell scripts (_fix_mojibake.ps1, _hex_scan.ps1, etc.) - Move test files into tests/ subdirectories for consistent project structure - Add trader-focused bullet points to indicator documentation
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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.Batch(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.Batch(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.Batch(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.Batch(prices, period: 1));
|
||||
Assert.Throws<ArgumentException>(() => Hv.Batch(prices, period: 0));
|
||||
Assert.Throws<ArgumentException>(() => Hv.Batch(prices, period: -1));
|
||||
Assert.Throws<ArgumentException>(() => Hv.Batch(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
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
using Skender.Stock.Indicators;
|
||||
using Tulip;
|
||||
|
||||
namespace QuanTAlib.Test;
|
||||
|
||||
using QuanTAlib.Tests;
|
||||
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.Batch(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%");
|
||||
}
|
||||
|
||||
// === Tulip Cross-Validation ===
|
||||
|
||||
/// <summary>
|
||||
/// Validates HV against Tulip's <c>volatility</c> indicator (annualised HV, ×√252).
|
||||
/// Tulip uses: σ = stddev(log returns) × √252 which exactly matches
|
||||
/// QuanTAlib <c>Hv(period, annualize:true, annualPeriods:252)</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Hv_Matches_Tulip_Batch()
|
||||
{
|
||||
const int period = 20;
|
||||
var bars = GenerateTestData(500);
|
||||
double[] closeData = new double[bars.Count];
|
||||
for (int i = 0; i < bars.Count; i++) { closeData[i] = bars[i].Close; }
|
||||
|
||||
// QuanTAlib batch — annualised with 252 trading days (matches Tulip)
|
||||
var qResult = Hv.Batch(bars.Close, period, annualize: true, annualPeriods: 252);
|
||||
|
||||
// Tulip volatility indicator
|
||||
var tulipIndicator = Tulip.Indicators.volatility;
|
||||
double[][] inputs = { closeData };
|
||||
double[] options = { period };
|
||||
int lookback = tulipIndicator.Start(options);
|
||||
double[][] outputs = { new double[closeData.Length - lookback] };
|
||||
tulipIndicator.Run(inputs, options, outputs);
|
||||
double[] tResult = outputs[0];
|
||||
|
||||
// Tulip volatility annualisation produces ~4e-6 divergence vs QuanTAlib — intentional.
|
||||
ValidationHelper.VerifyData(qResult, tResult, lookback, tolerance: 1e-5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hv_Matches_Tulip_Streaming()
|
||||
{
|
||||
const int period = 14;
|
||||
var bars = GenerateTestData(500);
|
||||
double[] closeData = new double[bars.Count];
|
||||
for (int i = 0; i < bars.Count; i++) { closeData[i] = bars[i].Close; }
|
||||
|
||||
// QuanTAlib streaming
|
||||
var hv = new Hv(period, annualize: true, annualPeriods: 252);
|
||||
var qResults = new List<double>();
|
||||
foreach (var bar in bars) { qResults.Add(hv.Update(new TValue(bar.Time, bar.Close)).Value); }
|
||||
|
||||
// Tulip
|
||||
var tulipIndicator = Tulip.Indicators.volatility;
|
||||
double[][] inputs = { closeData };
|
||||
double[] options = { period };
|
||||
int lookback = tulipIndicator.Start(options);
|
||||
double[][] outputs = { new double[closeData.Length - lookback] };
|
||||
tulipIndicator.Run(inputs, options, outputs);
|
||||
double[] tResult = outputs[0];
|
||||
|
||||
// Tulip volatility annualisation produces ~4e-6 divergence vs QuanTAlib — intentional.
|
||||
ValidationHelper.VerifyData(qResults, tResult, lookback, tolerance: 1e-5);
|
||||
}
|
||||
|
||||
// === Skender Cross-Validation ===
|
||||
|
||||
/// <summary>
|
||||
/// Validates HV against Skender <c>GetStdDev</c> on log returns.
|
||||
/// Skender returns sample standard deviation, so values are converted to
|
||||
/// population standard deviation by multiplying with √((n-1)/n).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_Skender_LogReturnsStdDev_NonAnnualized()
|
||||
{
|
||||
using var data = new ValidationTestData();
|
||||
const int period = 14;
|
||||
|
||||
var qResult = Hv.Batch(data.Data, period, annualize: false);
|
||||
var logReturnQuotes = BuildLogReturnQuotes(data.SkenderQuotes);
|
||||
var sResult = logReturnQuotes.GetStdDev(period).ToList();
|
||||
|
||||
int compared = 0;
|
||||
|
||||
for (int priceIdx = period; priceIdx < qResult.Count; priceIdx++)
|
||||
{
|
||||
double qValue = qResult[priceIdx].Value;
|
||||
double? sPop = sResult[priceIdx - 1].StdDev;
|
||||
|
||||
if (!sPop.HasValue || !double.IsFinite(sPop.Value) || !double.IsFinite(qValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double expected = sPop.Value;
|
||||
double diff = Math.Abs(qValue - expected);
|
||||
|
||||
Assert.True(
|
||||
diff <= 1e-10,
|
||||
$"Mismatch at priceIdx={priceIdx}: QuanTAlib={qValue:G17}, Skender(pop)={sPop.Value:G17}, Expected(pop)={expected:G17}, Diff={diff:G17}");
|
||||
|
||||
compared++;
|
||||
}
|
||||
|
||||
Assert.True(compared > 100, $"Expected >100 comparisons, got {compared}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates annualized HV against Skender log-returns StdDev with matching
|
||||
/// population conversion and annualization factor (√252).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_Skender_LogReturnsStdDev_Annualized()
|
||||
{
|
||||
using var data = new ValidationTestData();
|
||||
const int period = 14;
|
||||
const int annualPeriods = 252;
|
||||
|
||||
var qResult = Hv.Batch(data.Data, period, annualize: true, annualPeriods: annualPeriods);
|
||||
var logReturnQuotes = BuildLogReturnQuotes(data.SkenderQuotes);
|
||||
var sResult = logReturnQuotes.GetStdDev(period).ToList();
|
||||
|
||||
double annualFactor = Math.Sqrt(annualPeriods);
|
||||
int compared = 0;
|
||||
|
||||
for (int priceIdx = period; priceIdx < qResult.Count; priceIdx++)
|
||||
{
|
||||
double qValue = qResult[priceIdx].Value;
|
||||
double? sPop = sResult[priceIdx - 1].StdDev;
|
||||
|
||||
if (!sPop.HasValue || !double.IsFinite(sPop.Value) || !double.IsFinite(qValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
double expected = sPop.Value * annualFactor;
|
||||
double diff = Math.Abs(qValue - expected);
|
||||
|
||||
Assert.True(
|
||||
diff <= 1e-9,
|
||||
$"Mismatch at priceIdx={priceIdx}: QuanTAlib={qValue:G17}, Skender(pop)={sPop.Value:G17}, Expected(annualized pop)={expected:G17}, Diff={diff:G17}");
|
||||
|
||||
compared++;
|
||||
}
|
||||
|
||||
Assert.True(compared > 100, $"Expected >100 comparisons, got {compared}");
|
||||
}
|
||||
|
||||
// === Helper Methods ===
|
||||
|
||||
private static List<Quote> BuildLogReturnQuotes(IReadOnlyList<Quote> quotes)
|
||||
{
|
||||
var returns = new List<Quote>(Math.Max(0, quotes.Count - 1));
|
||||
for (int i = 1; i < quotes.Count; i++)
|
||||
{
|
||||
double prev = (double)quotes[i - 1].Close;
|
||||
double cur = (double)quotes[i].Close;
|
||||
double logReturn = Math.Log(cur / prev);
|
||||
|
||||
returns.Add(new Quote
|
||||
{
|
||||
Date = quotes[i].Date,
|
||||
Open = (decimal)logReturn,
|
||||
High = (decimal)logReturn,
|
||||
Low = (decimal)logReturn,
|
||||
Close = (decimal)logReturn,
|
||||
Volume = 0m
|
||||
});
|
||||
}
|
||||
|
||||
return returns;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user