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
+311
View File
@@ -0,0 +1,311 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class EwmaIndicatorTests
{
[Fact]
public void EwmaIndicator_Constructor_SetsDefaults()
{
var indicator = new EwmaIndicator();
Assert.Equal(20, indicator.Period);
Assert.True(indicator.AnnualizeVol);
Assert.Equal(252, indicator.AnnualPeriods);
Assert.True(indicator.ShowColdValues);
Assert.Equal("EWMA - Exponentially Weighted Moving Average Volatility", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void EwmaIndicator_ShortName_IncludesParameters()
{
var indicator = new EwmaIndicator { Period = 14, AnnualizeVol = true, AnnualPeriods = 252 };
Assert.Contains("EWMA", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("14", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void EwmaIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new EwmaIndicator();
Assert.Equal(0, EwmaIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void EwmaIndicator_Initialize_CreatesInternalEwma()
{
var indicator = new EwmaIndicator();
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void EwmaIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new EwmaIndicator { Period = 5, AnnualizeVol = false };
indicator.Initialize();
// Add historical data with varying prices
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
double basePrice = 100 + i + (i % 5); // Varying prices
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 2, basePrice - 2, basePrice + 1, 1000);
// Process update for each bar to simulate history loading
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
// Line series should have a value
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val));
}
[Fact]
public void EwmaIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new EwmaIndicator { Period = 5, AnnualizeVol = false };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
double basePrice = 100 + i;
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 2, 1000);
}
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Add new bar with price change
indicator.HistoricalData.AddBar(now.AddMinutes(30), 120, 125, 115, 122, 1500);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void EwmaIndicator_DifferentPeriods_Work()
{
int[] periods = { 5, 10, 14, 20 };
foreach (var period in periods)
{
var indicator = new EwmaIndicator { Period = period, AnnualizeVol = false };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 50; i++)
{
double basePrice = 100 + i + (i % 4);
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 3, basePrice - 3, basePrice + 1, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val), $"Period {period} should produce finite value");
}
}
[Fact]
public void EwmaIndicator_DifferentAnnualPeriods_Work()
{
int[] annualPeriods = { 12, 52, 252, 365 };
foreach (var annualPeriod in annualPeriods)
{
var indicator = new EwmaIndicator { Period = 10, AnnualizeVol = true, AnnualPeriods = annualPeriod };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 50; i++)
{
double basePrice = 100 + i + (i % 4);
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 3, basePrice - 3, basePrice + 1, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val), $"Annual period {annualPeriod} should produce finite value");
}
}
[Fact]
public void EwmaIndicator_Period_CanBeChanged()
{
var indicator = new EwmaIndicator();
Assert.Equal(20, indicator.Period);
indicator.Period = 14;
Assert.Equal(14, indicator.Period);
indicator.Period = 30;
Assert.Equal(30, indicator.Period);
}
[Fact]
public void EwmaIndicator_AnnualizeVol_CanBeToggled()
{
var indicator = new EwmaIndicator();
Assert.True(indicator.AnnualizeVol);
indicator.AnnualizeVol = false;
Assert.False(indicator.AnnualizeVol);
indicator.AnnualizeVol = true;
Assert.True(indicator.AnnualizeVol);
}
[Fact]
public void EwmaIndicator_AnnualPeriods_CanBeChanged()
{
var indicator = new EwmaIndicator();
Assert.Equal(252, indicator.AnnualPeriods);
indicator.AnnualPeriods = 52;
Assert.Equal(52, indicator.AnnualPeriods);
indicator.AnnualPeriods = 365;
Assert.Equal(365, indicator.AnnualPeriods);
}
[Fact]
public void EwmaIndicator_ShowColdValues_CanBeToggled()
{
var indicator = new EwmaIndicator();
Assert.True(indicator.ShowColdValues);
indicator.ShowColdValues = false;
Assert.False(indicator.ShowColdValues);
indicator.ShowColdValues = true;
Assert.True(indicator.ShowColdValues);
}
[Fact]
public void EwmaIndicator_SourceCodeLink_IsValid()
{
var indicator = new EwmaIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Ewma.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void EwmaIndicator_ConstantPrices_ProducesZeroVolatility()
{
var indicator = new EwmaIndicator { Period = 5, AnnualizeVol = false };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 100, 100, 100, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val), "Constant prices should produce finite value");
Assert.Equal(0.0, val, 1e-10);
}
[Fact]
public void EwmaIndicator_VolatilePrices_ProducesPositiveVolatility()
{
var indicator = new EwmaIndicator { Period = 5, AnnualizeVol = false };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
// Alternating prices to create volatility
double price = (i % 2 == 0) ? 100 : 110;
indicator.HistoricalData.AddBar(now.AddMinutes(i), price, price + 2, price - 2, price, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val), "Volatile prices should produce finite value");
Assert.True(val > 0, "Volatile prices should produce positive volatility");
}
[Fact]
public void EwmaIndicator_AnnualizationMultipliesVolatility()
{
var indicatorNoAnn = new EwmaIndicator { Period = 10, AnnualizeVol = false };
var indicatorAnn = new EwmaIndicator { Period = 10, AnnualizeVol = true, AnnualPeriods = 252 };
indicatorNoAnn.Initialize();
indicatorAnn.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 50; i++)
{
double price = 100 + i + (i % 5);
indicatorNoAnn.HistoricalData.AddBar(now.AddMinutes(i), price, price + 2, price - 2, price, 1000);
indicatorAnn.HistoricalData.AddBar(now.AddMinutes(i), price, price + 2, price - 2, price, 1000);
indicatorNoAnn.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicatorAnn.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double valNoAnn = indicatorNoAnn.LinesSeries[0].GetValue(0);
double valAnn = indicatorAnn.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(valNoAnn));
Assert.True(double.IsFinite(valAnn));
// Annualized should be approximately sqrt(252) times larger
if (valNoAnn > 1e-10)
{
double ratio = valAnn / valNoAnn;
double expectedRatio = Math.Sqrt(252);
Assert.True(Math.Abs(ratio - expectedRatio) < 0.01,
$"Annualized volatility ratio should be ~{expectedRatio}, got {ratio}");
}
}
[Fact]
public void EwmaIndicator_ShorterPeriod_MoreResponsive()
{
var indicatorShort = new EwmaIndicator { Period = 5, AnnualizeVol = false };
var indicatorLong = new EwmaIndicator { Period = 50, AnnualizeVol = false };
indicatorShort.Initialize();
indicatorLong.Initialize();
var now = DateTime.UtcNow;
// Build up history with low volatility
for (int i = 0; i < 60; i++)
{
indicatorShort.HistoricalData.AddBar(now.AddMinutes(i), 100, 101, 99, 100, 1000);
indicatorLong.HistoricalData.AddBar(now.AddMinutes(i), 100, 101, 99, 100, 1000);
indicatorShort.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicatorLong.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double shortBefore = indicatorShort.LinesSeries[0].GetValue(0);
double longBefore = indicatorLong.LinesSeries[0].GetValue(0);
// Inject shock
indicatorShort.HistoricalData.AddBar(now.AddMinutes(60), 100, 120, 80, 110, 1500);
indicatorLong.HistoricalData.AddBar(now.AddMinutes(60), 100, 120, 80, 110, 1500);
indicatorShort.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
indicatorLong.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
double shortAfter = indicatorShort.LinesSeries[0].GetValue(0);
double longAfter = indicatorLong.LinesSeries[0].GetValue(0);
double shortIncrease = shortAfter - shortBefore;
double longIncrease = longAfter - longBefore;
Assert.True(shortIncrease > longIncrease,
"Shorter period should respond more strongly to shocks");
}
}
+65
View File
@@ -0,0 +1,65 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class EwmaIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
public int Period { get; set; } = 20;
[InputParameter("Annualize", sortIndex: 2)]
public bool AnnualizeVol { get; set; } = true;
[InputParameter("Annual Periods", sortIndex: 3, 1, 365, 1, 0)]
public int AnnualPeriods { get; set; } = 252;
[IndicatorExtensions.DataSourceInput]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Ewma _ewma = null!;
private readonly LineSeries _series;
private string _sourceName = null!;
private Func<IHistoryItem, double> _priceSelector = null!;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => AnnualizeVol
? $"EWMA {Period},{AnnualPeriods}:{_sourceName}"
: $"EWMA {Period}:{_sourceName}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/volatility/ewma/Ewma.Quantower.cs";
public EwmaIndicator()
{
OnBackGround = true;
SeparateWindow = true;
_sourceName = Source.ToString();
Name = "EWMA - Exponentially Weighted Moving Average Volatility";
Description = "EWMA Volatility calculates volatility using an exponentially weighted moving average of squared log returns with bias correction";
_series = new LineSeries(name: "EWMA", color: IndicatorExtensions.Volatility, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
protected override void OnInit()
{
_ewma = new Ewma(Period, AnnualizeVol, AnnualPeriods);
_sourceName = Source.ToString();
_priceSelector = Source.GetPriceSelector();
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
var item = HistoricalData[Count - 1, SeekOriginHistory.Begin];
TValue result = _ewma.Update(new TValue(item.TimeLeft.Ticks, _priceSelector(item)), isNew: args.IsNewBar());
_series.SetValue(result.Value, _ewma.IsHot, ShowColdValues);
}
}
+553
View File
@@ -0,0 +1,553 @@
namespace QuanTAlib.Tests;
using Xunit;
public class EwmaTests
{
private static TBarSeries GenerateTestData(int count = 100)
{
var gbm = new GBM(seed: 42);
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
}
[Fact]
public void Constructor_ValidatesInput()
{
Assert.Throws<ArgumentException>(() => new Ewma(0));
Assert.Throws<ArgumentException>(() => new Ewma(-1));
Assert.Throws<ArgumentException>(() => new Ewma(20, annualize: true, annualPeriods: 0));
Assert.Throws<ArgumentException>(() => new Ewma(20, annualize: true, annualPeriods: -1));
var valid = new Ewma(10, true, 252);
Assert.Equal(10, valid.Period);
Assert.True(valid.Annualize);
Assert.Equal(252, valid.AnnualPeriods);
}
[Fact]
public void WarmupPeriod_IsCorrect()
{
var ewma = new Ewma(20);
Assert.Equal(20, ewma.WarmupPeriod);
Assert.True(ewma.WarmupPeriod > 0);
}
[Fact]
public void Properties_Accessible()
{
var ewma = new Ewma(20, true, 252);
Assert.Equal(20, ewma.Period);
Assert.True(ewma.Annualize);
Assert.Equal(252, ewma.AnnualPeriods);
Assert.Equal("Ewma(20,252)", ewma.Name);
var ewmaNoAnn = new Ewma(15, false);
Assert.Equal("Ewma(15)", ewmaNoAnn.Name);
}
[Fact]
public void BasicCalculation_DoesNotCrash()
{
var ewma = new Ewma(5);
var bars = GenerateTestData(100);
var times = bars.Times;
var close = bars.CloseValues;
for (int i = 0; i < bars.Count; i++)
{
var result = ewma.Update(new TValue(times[i], close[i]));
Assert.True(double.IsFinite(result.Value));
}
}
[Fact]
public void Calc_ReturnsValue()
{
var ewma = new Ewma(10);
for (int i = 0; i < 15; i++)
{
var result = ewma.Update(new TValue(DateTime.UtcNow, 100 + i));
Assert.True(double.IsFinite(result.Value));
}
Assert.True(ewma.IsHot);
}
[Fact]
public void Calc_IsNew_AcceptsParameter()
{
var ewma = new Ewma(10);
var result1 = ewma.Update(new TValue(DateTime.UtcNow, 100), isNew: true);
var result2 = ewma.Update(new TValue(DateTime.UtcNow, 101), isNew: true);
var result3 = ewma.Update(new TValue(DateTime.UtcNow, 102), isNew: false);
Assert.True(double.IsFinite(result1.Value));
Assert.True(double.IsFinite(result2.Value));
Assert.True(double.IsFinite(result3.Value));
}
[Fact]
public void Calc_IsNew_False_UpdatesValue()
{
var ewma = new Ewma(5);
for (int i = 0; i < 10; i++)
{
ewma.Update(new TValue(DateTime.UtcNow, 100 + i), isNew: true);
}
var baseline = ewma.Update(new TValue(DateTime.UtcNow, 110), isNew: true);
var updated = ewma.Update(new TValue(DateTime.UtcNow, 150), isNew: false);
Assert.NotEqual(baseline.Value, updated.Value);
}
[Fact]
public void IsHot_BecomesTrueAfterWarmup()
{
int period = 10;
var ewma = new Ewma(period);
for (int i = 0; i < period - 1; i++)
{
ewma.Update(new TValue(DateTime.UtcNow, 100 + i));
Assert.False(ewma.IsHot);
}
ewma.Update(new TValue(DateTime.UtcNow, 110));
Assert.True(ewma.IsHot);
}
[Fact]
public void Reset_Works()
{
var ewma = new Ewma(10);
for (int i = 0; i < 15; i++)
{
ewma.Update(new TValue(DateTime.UtcNow, 100 + i));
}
Assert.True(ewma.IsHot);
ewma.Reset();
Assert.False(ewma.IsHot);
}
[Fact]
public void SingleValue_ReturnsZeroVolatility()
{
var ewma = new Ewma(5);
var result = ewma.Update(new TValue(DateTime.UtcNow, 100));
// First value should return 0 (no return to calculate)
Assert.True(double.IsFinite(result.Value));
Assert.True(result.Value >= 0);
}
[Fact]
public void IterativeCorrections_ChangesValue()
{
var ewma = new Ewma(20);
var bars = GenerateTestData(50);
var times = bars.Times;
var close = bars.CloseValues;
TValue lastValue = default;
for (int i = 0; i < bars.Count; i++)
{
lastValue = ewma.Update(new TValue(times[i], close[i]), isNew: true);
}
double originalValue = lastValue.Value;
// Verify that isNew=false with different price produces different output
var correctedValue = ewma.Update(new TValue(DateTime.UtcNow, 999.99), isNew: false);
Assert.NotEqual(originalValue, correctedValue.Value);
// Verify output is still finite and positive
Assert.True(double.IsFinite(correctedValue.Value));
Assert.True(correctedValue.Value >= 0);
}
[Fact]
public void IsNew_Consistency()
{
var ewma = new Ewma(10);
for (int i = 0; i < 10; i++)
{
ewma.Update(new TValue(DateTime.UtcNow, 100 + i), isNew: true);
}
var result1 = ewma.Update(new TValue(DateTime.UtcNow, 110), isNew: true);
_ = ewma.Update(new TValue(DateTime.UtcNow, 115), isNew: false);
var result3 = ewma.Update(new TValue(DateTime.UtcNow, 110), isNew: false);
// With same input, should get same output after rollback
Assert.Equal(result1.Value, result3.Value, 1e-9);
}
[Fact]
public void NaN_Input_UsesLastValidValue()
{
var ewma = new Ewma(5);
for (int i = 0; i < 10; i++)
{
ewma.Update(new TValue(DateTime.UtcNow, 100 + i));
}
var resultNan = ewma.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsFinite(resultNan.Value));
}
[Fact]
public void Infinity_Input_UsesLastValidValue()
{
var ewma = new Ewma(5);
for (int i = 0; i < 10; i++)
{
ewma.Update(new TValue(DateTime.UtcNow, 100 + i));
}
var resultInf = ewma.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
Assert.True(double.IsFinite(resultInf.Value));
}
[Fact]
public void LargeDataset_Performance()
{
var ewma = new Ewma(50);
var bars = GenerateTestData(5000);
var times = bars.Times;
var close = bars.CloseValues;
for (int i = 0; i < bars.Count; i++)
{
var result = ewma.Update(new TValue(times[i], close[i]));
Assert.True(double.IsFinite(result.Value));
}
}
[Fact]
public void TSeries_Update_MatchesStreaming()
{
int period = 20;
var ewmaStream = new Ewma(period);
var ewmaBatch = new Ewma(period);
var bars = GenerateTestData(100);
var times = bars.Times;
var close = bars.CloseValues;
for (int i = 0; i < bars.Count; i++)
{
ewmaStream.Update(new TValue(times[i], close[i]));
}
var ts = new TSeries();
for (int i = 0; i < bars.Count; i++)
{
ts.Add(new TValue(times[i], close[i]));
}
var result = ewmaBatch.Update(ts);
Assert.Equal(ewmaStream.Last.Value, result[result.Count - 1].Value, 1e-9);
}
[Fact]
public void BatchCalc_MatchesIterativeCalc()
{
var ewma = new Ewma(20);
var bars = GenerateTestData(200);
var times = bars.Times;
var close = bars.CloseValues;
for (int i = 0; i < bars.Count; i++)
{
ewma.Update(new TValue(times[i], close[i]));
}
var iterativeResult = ewma.Last.Value;
var ts = new TSeries();
for (int i = 0; i < bars.Count; i++)
{
ts.Add(new TValue(times[i], close[i]));
}
var batchResult = Ewma.Calculate(ts, 20);
Assert.Equal(iterativeResult, batchResult[batchResult.Count - 1].Value, 1e-8);
}
[Fact]
public void StaticBatch_Works()
{
var bars = GenerateTestData(100);
var times = bars.Times;
var close = bars.CloseValues;
var ts = new TSeries();
for (int i = 0; i < bars.Count; i++)
{
ts.Add(new TValue(times[i], close[i]));
}
var result = Ewma.Calculate(ts, 20);
Assert.Equal(100, result.Count);
Assert.True(double.IsFinite(result[result.Count - 1].Value));
}
[Fact]
public void StaticBatch_ValidatesInput()
{
var ts = new TSeries();
for (int i = 0; i < 10; i++)
{
ts.Add(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + i));
}
Assert.Throws<ArgumentException>(() => Ewma.Calculate(ts, 0));
Assert.Throws<ArgumentException>(() => Ewma.Calculate(ts, -1));
Assert.Throws<ArgumentException>(() => Ewma.Calculate(ts, 5, true, 0));
Assert.Throws<ArgumentException>(() => Ewma.Calculate(ts, 5, true, -1));
}
[Fact]
public void Batch_NaN_Safe()
{
var values = new double[] { 100, 101, 102, double.NaN, 104, 105 };
var output = new double[values.Length];
Ewma.Batch(values, output, 3);
Assert.True(output.Length == 6);
for (int i = 0; i < output.Length; i++)
{
Assert.True(double.IsFinite(output[i]));
}
}
[Fact]
public void ConstantPrices_ZeroVolatility()
{
var ewma = new Ewma(10, false); // Not annualized
for (int i = 0; i < 20; i++)
{
ewma.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0));
}
// Constant prices should have zero volatility (log returns = 0)
Assert.True(ewma.Last.Value < 1e-10, "Constant prices should have near-zero volatility");
}
[Fact]
public void HighVolatility_ProducesHigherValue()
{
var ewmaStable = new Ewma(10, false);
var ewmaVolatile = new Ewma(10, false);
// Stable prices (small changes)
for (int i = 0; i < 20; i++)
{
ewmaStable.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + i * 0.01));
}
// Volatile prices (alternating)
for (int i = 0; i < 20; i++)
{
double volatilePrice = 100 + (i % 2 == 0 ? 5 : -5);
ewmaVolatile.Update(new TValue(DateTime.UtcNow.AddMinutes(i), volatilePrice));
}
Assert.True(ewmaVolatile.Last.Value > ewmaStable.Last.Value,
"Higher volatility should produce higher EWMA");
}
[Fact]
public void Annualization_ScalesCorrectly()
{
var ewmaNoAnn = new Ewma(10, false);
var ewmaAnn252 = new Ewma(10, true, 252);
var bars = GenerateTestData(50);
var times = bars.Times;
var close = bars.CloseValues;
for (int i = 0; i < bars.Count; i++)
{
ewmaNoAnn.Update(new TValue(times[i], close[i]));
ewmaAnn252.Update(new TValue(times[i], close[i]));
}
double expectedRatio = Math.Sqrt(252);
double actualRatio = ewmaAnn252.Last.Value / ewmaNoAnn.Last.Value;
Assert.True(Math.Abs(actualRatio - expectedRatio) < 0.01,
$"Annualization should scale by sqrt(252). Expected ratio: {expectedRatio}, Actual: {actualRatio}");
}
[Fact]
public void DifferentAnnualPeriods_ProduceDistinctValues()
{
var ewma252 = new Ewma(10, true, 252); // Daily
var ewma52 = new Ewma(10, true, 52); // Weekly
var ewma12 = new Ewma(10, true, 12); // Monthly
var bars = GenerateTestData(50);
var times = bars.Times;
var close = bars.CloseValues;
for (int i = 0; i < bars.Count; i++)
{
ewma252.Update(new TValue(times[i], close[i]));
ewma52.Update(new TValue(times[i], close[i]));
ewma12.Update(new TValue(times[i], close[i]));
}
// Higher annual periods = higher annualized volatility
Assert.True(ewma252.Last.Value > ewma52.Last.Value, "Daily annualization should be higher than weekly");
Assert.True(ewma52.Last.Value > ewma12.Last.Value, "Weekly annualization should be higher than monthly");
}
[Fact]
public void BiasCorrection_WorksForEarlyValues()
{
// EWMA with bias correction should provide reasonable estimates even early
var ewma = new Ewma(20, false);
// First few values
ewma.Update(new TValue(DateTime.UtcNow, 100));
var first = ewma.Update(new TValue(DateTime.UtcNow.AddMinutes(1), 101));
var second = ewma.Update(new TValue(DateTime.UtcNow.AddMinutes(2), 99));
// Should produce finite values even before warmup
Assert.True(double.IsFinite(first.Value));
Assert.True(double.IsFinite(second.Value));
Assert.True(second.Value > 0, "Should detect volatility after price changes");
}
[Fact]
public void Chainability_Works()
{
var ewma = new Ewma(20);
var sma = new Sma(5);
var bars = GenerateTestData(100);
var times = bars.Times;
var close = bars.CloseValues;
for (int i = 0; i < bars.Count; i++)
{
var ewmaResult = ewma.Update(new TValue(times[i], close[i]));
sma.Update(ewmaResult);
}
Assert.True(sma.IsHot);
Assert.True(double.IsFinite(sma.Last.Value));
}
[Fact]
public void SpanBatch_ValidatesLengths()
{
var source = new double[] { 100, 101, 102, 103, 104 };
var outputShort = new double[3];
Assert.Throws<ArgumentException>(() => Ewma.Batch(source, outputShort, 3));
}
[Fact]
public void SpanBatch_ValidatesPeriod()
{
var source = new double[] { 100, 101, 102, 103, 104 };
var output = new double[5];
Assert.Throws<ArgumentException>(() => Ewma.Batch(source, output, 0));
Assert.Throws<ArgumentException>(() => Ewma.Batch(source, output, -1));
}
[Fact]
public void SpanBatch_ValidatesAnnualPeriods()
{
var source = new double[] { 100, 101, 102, 103, 104 };
var output = new double[5];
Assert.Throws<ArgumentException>(() => Ewma.Batch(source, output, 3, true, 0));
Assert.Throws<ArgumentException>(() => Ewma.Batch(source, output, 3, true, -1));
}
[Fact]
public void SpanBatch_MatchesStreaming()
{
var ewma = new Ewma(10, true, 252);
var bars = GenerateTestData(100);
var close = bars.CloseValues;
// Streaming
for (int i = 0; i < bars.Count; i++)
{
ewma.Update(new TValue(DateTime.UtcNow, close[i]));
}
// Batch
var output = new double[close.Length];
Ewma.Batch(close, output, 10, true, 252);
// Compare last values
Assert.Equal(ewma.Last.Value, output[output.Length - 1], 1e-9);
}
[Fact]
public void EmptyInput_HandledGracefully()
{
var source = ReadOnlySpan<double>.Empty;
var output = Span<double>.Empty;
// Should not throw - empty spans are valid
Ewma.Batch(source, output, 10);
Assert.True(true, "Empty input handled without exception");
}
[Fact]
public void LogReturns_CalculatedCorrectly()
{
// Test with known values to verify log return calculation
var ewma = new Ewma(2, false); // Short period for quick testing
// Price goes from 100 to 110 (+10%)
ewma.Update(new TValue(DateTime.UtcNow, 100));
var result = ewma.Update(new TValue(DateTime.UtcNow.AddMinutes(1), 110));
// Log return = ln(110/100) ≈ 0.0953
// Squared return ≈ 0.00908
// With bias correction, volatility should be close to |log return|
Assert.True(result.Value > 0);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void NegativePrice_UsesLastValid()
{
var ewma = new Ewma(5);
ewma.Update(new TValue(DateTime.UtcNow, 100));
ewma.Update(new TValue(DateTime.UtcNow.AddMinutes(1), 101));
var resultNeg = ewma.Update(new TValue(DateTime.UtcNow.AddMinutes(2), -50));
Assert.True(double.IsFinite(resultNeg.Value));
Assert.True(resultNeg.Value >= 0);
}
[Fact]
public void ZeroPrice_UsesLastValid()
{
var ewma = new Ewma(5);
ewma.Update(new TValue(DateTime.UtcNow, 100));
ewma.Update(new TValue(DateTime.UtcNow.AddMinutes(1), 101));
var resultZero = ewma.Update(new TValue(DateTime.UtcNow.AddMinutes(2), 0));
Assert.True(double.IsFinite(resultZero.Value));
Assert.True(resultZero.Value >= 0);
}
}
@@ -0,0 +1,495 @@
namespace QuanTAlib.Tests;
using Xunit;
/// <summary>
/// Validation tests for EWMA Volatility indicator.
/// Note: EWMA Volatility as implemented is based on PineScript reference.
/// External library validation may not be available.
/// </summary>
public class EwmaValidationTests
{
private readonly int DefaultPeriod = 20;
private readonly bool DefaultAnnualize = true;
private readonly int DefaultAnnualPeriods = 252;
private const double StreamingTolerance = 1e-9;
private static TBarSeries GenerateTestData(int count = 500)
{
var gbm = new GBM(seed: 42);
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
}
private static TSeries ToTSeries(TBarSeries bars)
{
var ts = new TSeries();
var times = bars.Times;
var close = bars.CloseValues;
for (int i = 0; i < bars.Count; i++)
{
ts.Add(new TValue(times[i], close[i]));
}
return ts;
}
// ============ Mathematical Property Validation ============
[Fact]
public void MathProperty_ReturnsAreSquared()
{
// EWMA should always produce non-negative values (sqrt of squared returns)
var ewma = new Ewma(10, false);
var bars = GenerateTestData(100);
var close = bars.CloseValues;
for (int i = 0; i < bars.Count; i++)
{
var result = ewma.Update(new TValue(DateTime.UtcNow, close[i]));
Assert.True(result.Value >= 0, $"EWMA should be non-negative, got {result.Value} at index {i}");
}
}
[Fact]
public void MathProperty_AnnualizationFactor()
{
// Annualized vol = periodic vol × √(annual periods)
var ewmaNoAnn = new Ewma(DefaultPeriod, false);
var ewmaAnn252 = new Ewma(DefaultPeriod, true, 252);
var ewmaAnn52 = new Ewma(DefaultPeriod, true, 52);
var ewmaAnn12 = new Ewma(DefaultPeriod, true, 12);
var bars = GenerateTestData(100);
var close = bars.CloseValues;
var times = bars.Times;
for (int i = 0; i < bars.Count; i++)
{
ewmaNoAnn.Update(new TValue(times[i], close[i]));
ewmaAnn252.Update(new TValue(times[i], close[i]));
ewmaAnn52.Update(new TValue(times[i], close[i]));
ewmaAnn12.Update(new TValue(times[i], close[i]));
}
double periodicVol = ewmaNoAnn.Last.Value;
if (periodicVol > 1e-10) // Only test if there's measurable volatility
{
Assert.Equal(periodicVol * Math.Sqrt(252), ewmaAnn252.Last.Value, 1e-9);
Assert.Equal(periodicVol * Math.Sqrt(52), ewmaAnn52.Last.Value, 1e-9);
Assert.Equal(periodicVol * Math.Sqrt(12), ewmaAnn12.Last.Value, 1e-9);
}
}
[Fact]
public void MathProperty_BiasCorrection_ConvergesToOne()
{
// Bias correction factor (1 - decay^n) should approach 1 as n → ∞
// This means corrected and uncorrected values should converge
var ewma = new Ewma(20, false);
var bars = GenerateTestData(500);
var close = bars.CloseValues;
var times = bars.Times;
for (int i = 0; i < bars.Count; i++)
{
ewma.Update(new TValue(times[i], close[i]));
}
// After many observations, bias correction should be minimal
// We can't directly test the factor, but we can verify stability
Assert.True(ewma.IsHot);
Assert.True(double.IsFinite(ewma.Last.Value));
}
[Fact]
public void MathProperty_RMA_ExponentialDecay()
{
// RMA formula: new_rma = (old_rma × (period-1) + new_value) / period
// This is equivalent to EMA with alpha = 1/period
// Older values should have exponentially decaying influence
var ewma = new Ewma(10, false);
// Feed constant values to establish baseline
for (int i = 0; i < 50; i++)
{
ewma.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0));
}
double baselineVol = ewma.Last.Value;
// Inject a shock
ewma.Update(new TValue(DateTime.UtcNow.AddMinutes(50), 150.0)); // 50% jump
double shockVol = ewma.Last.Value;
Assert.True(shockVol > baselineVol, "Shock should increase volatility");
// Return to constant prices - volatility should decay
double[] vols = new double[30];
for (int i = 0; i < 30; i++)
{
ewma.Update(new TValue(DateTime.UtcNow.AddMinutes(51 + i), 100.0));
vols[i] = ewma.Last.Value;
}
// Verify monotonic decay (or near-monotonic)
int decayCount = 0;
for (int i = 1; i < vols.Length; i++)
{
if (vols[i] <= vols[i - 1] + 1e-10) // Allow small floating point noise
{
decayCount++;
}
}
Assert.True(decayCount >= 25, $"Volatility should decay over time, but only {decayCount}/29 periods showed decay");
}
// ============ Mode Consistency Validation ============
[Fact]
public void ModeConsistency_StreamingVsBatch()
{
var ewmaStream = new Ewma(DefaultPeriod, DefaultAnnualize, DefaultAnnualPeriods);
var bars = GenerateTestData(200);
var ts = ToTSeries(bars);
var close = bars.CloseValues;
var times = bars.Times;
// Streaming
for (int i = 0; i < bars.Count; i++)
{
ewmaStream.Update(new TValue(times[i], close[i]));
}
// Batch
var batchResult = Ewma.Calculate(ts, DefaultPeriod, DefaultAnnualize, DefaultAnnualPeriods);
Assert.Equal(ewmaStream.Last.Value, batchResult[batchResult.Count - 1].Value, StreamingTolerance);
}
[Fact]
public void ModeConsistency_StreamingVsSpan()
{
var ewmaStream = new Ewma(DefaultPeriod, DefaultAnnualize, DefaultAnnualPeriods);
var bars = GenerateTestData(200);
var close = bars.CloseValues;
var times = bars.Times;
// Streaming
for (int i = 0; i < bars.Count; i++)
{
ewmaStream.Update(new TValue(times[i], close[i]));
}
// Span
var output = new double[close.Length];
Ewma.Batch(close, output, DefaultPeriod, DefaultAnnualize, DefaultAnnualPeriods);
Assert.Equal(ewmaStream.Last.Value, output[output.Length - 1], StreamingTolerance);
}
[Fact]
public void ModeConsistency_TSeries_VsSpan()
{
var ewma = new Ewma(DefaultPeriod, DefaultAnnualize, DefaultAnnualPeriods);
var bars = GenerateTestData(200);
var ts = ToTSeries(bars);
var close = bars.CloseValues;
// TSeries
var tseriesResult = ewma.Update(ts);
// Span
var output = new double[close.Length];
Ewma.Batch(close, output, DefaultPeriod, DefaultAnnualize, DefaultAnnualPeriods);
Assert.Equal(tseriesResult[tseriesResult.Count - 1].Value, output[output.Length - 1], StreamingTolerance);
}
[Fact]
public void ModeConsistency_AllFourModes()
{
var bars = GenerateTestData(150);
var ts = ToTSeries(bars);
var close = bars.CloseValues;
var times = bars.Times;
// Mode 1: Streaming
var ewmaStream = new Ewma(DefaultPeriod, DefaultAnnualize, DefaultAnnualPeriods);
for (int i = 0; i < bars.Count; i++)
{
ewmaStream.Update(new TValue(times[i], close[i]));
}
double streamingResult = ewmaStream.Last.Value;
// Mode 2: TSeries Update
var ewmaTSeries = new Ewma(DefaultPeriod, DefaultAnnualize, DefaultAnnualPeriods);
var tseriesResult = ewmaTSeries.Update(ts);
double tseriesValue = tseriesResult[tseriesResult.Count - 1].Value;
// Mode 3: Static Calculate
var batchResult = Ewma.Calculate(ts, DefaultPeriod, DefaultAnnualize, DefaultAnnualPeriods);
double batchValue = batchResult[batchResult.Count - 1].Value;
// Mode 4: Span Batch
var output = new double[close.Length];
Ewma.Batch(close, output, DefaultPeriod, DefaultAnnualize, DefaultAnnualPeriods);
double spanValue = output[output.Length - 1];
// All four should match
Assert.Equal(streamingResult, tseriesValue, StreamingTolerance);
Assert.Equal(streamingResult, batchValue, StreamingTolerance);
Assert.Equal(streamingResult, spanValue, StreamingTolerance);
}
// ============ Edge Case Validation ============
[Fact]
public void EdgeCase_SingleValue()
{
var ewma = new Ewma(5, false);
var result = ewma.Update(new TValue(DateTime.UtcNow, 100));
// Single value should return 0 volatility (no return yet)
Assert.True(double.IsFinite(result.Value));
Assert.Equal(0.0, result.Value, 1e-10);
}
[Fact]
public void EdgeCase_TwoValues()
{
var ewma = new Ewma(5, false);
ewma.Update(new TValue(DateTime.UtcNow, 100));
var result = ewma.Update(new TValue(DateTime.UtcNow.AddMinutes(1), 110));
// With price change, should have positive volatility
Assert.True(result.Value > 0, "Should detect volatility from price change");
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void EdgeCase_AllNaN()
{
var ewma = new Ewma(5);
for (int i = 0; i < 10; i++)
{
var result = ewma.Update(new TValue(DateTime.UtcNow.AddMinutes(i), double.NaN));
Assert.True(double.IsFinite(result.Value));
}
}
[Fact]
public void EdgeCase_MixedNaN()
{
var ewma = new Ewma(5);
double[] prices = { 100, 101, double.NaN, 103, double.NaN, double.NaN, 106 };
foreach (double price in prices)
{
var result = ewma.Update(new TValue(DateTime.UtcNow, price));
Assert.True(double.IsFinite(result.Value));
}
}
[Fact]
public void EdgeCase_VerySmallPrices()
{
var ewma = new Ewma(5, false);
for (int i = 0; i < 20; i++)
{
double price = 0.0001 + (i % 2) * 0.00001;
var result = ewma.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price));
Assert.True(double.IsFinite(result.Value));
Assert.True(result.Value >= 0);
}
}
[Fact]
public void EdgeCase_VeryLargePrices()
{
var ewma = new Ewma(5, false);
for (int i = 0; i < 20; i++)
{
double price = 1e10 + (i % 2) * 1e9;
var result = ewma.Update(new TValue(DateTime.UtcNow.AddMinutes(i), price));
Assert.True(double.IsFinite(result.Value));
Assert.True(result.Value >= 0);
}
}
[Fact]
public void EdgeCase_Period1()
{
var ewma = new Ewma(1, false);
ewma.Update(new TValue(DateTime.UtcNow, 100));
var result = ewma.Update(new TValue(DateTime.UtcNow.AddMinutes(1), 110));
// Period 1 means volatility is just |log return|
double expectedLogReturn = Math.Abs(Math.Log(110.0 / 100.0));
Assert.True(Math.Abs(result.Value - expectedLogReturn) < 0.01,
$"Period 1 EWMA should equal |log return|. Expected ~{expectedLogReturn}, got {result.Value}");
}
[Fact]
public void EdgeCase_LargePeriod()
{
var ewma = new Ewma(500, false);
var bars = GenerateTestData(600);
var close = bars.CloseValues;
var times = bars.Times;
for (int i = 0; i < bars.Count; i++)
{
var result = ewma.Update(new TValue(times[i], close[i]));
Assert.True(double.IsFinite(result.Value));
}
Assert.True(ewma.IsHot);
}
// ============ Stability Validation ============
[Fact]
public void Stability_LongRunningCalculation()
{
var ewma = new Ewma(20);
var bars = GenerateTestData(5000);
var close = bars.CloseValues;
var times = bars.Times;
for (int i = 0; i < bars.Count; i++)
{
var result = ewma.Update(new TValue(times[i], close[i]));
Assert.True(double.IsFinite(result.Value), $"Non-finite value at index {i}");
Assert.True(result.Value >= 0, $"Negative volatility at index {i}");
}
}
[Fact]
public void Stability_RepeatedReset()
{
var ewma = new Ewma(10);
var bars = GenerateTestData(50);
var close = bars.CloseValues;
var times = bars.Times;
for (int reset = 0; reset < 5; reset++)
{
ewma.Reset();
for (int i = 0; i < bars.Count; i++)
{
var result = ewma.Update(new TValue(times[i], close[i]));
Assert.True(double.IsFinite(result.Value));
}
}
}
[Fact]
public void Stability_BarCorrection_MultipleUpdates()
{
var ewma = new Ewma(10);
var bars = GenerateTestData(50);
var close = bars.CloseValues;
var times = bars.Times;
for (int i = 0; i < bars.Count; i++)
{
ewma.Update(new TValue(times[i], close[i]), isNew: true);
}
// Multiple corrections
for (int j = 0; j < 10; j++)
{
double correctedPrice = 100 + j * 5;
var result = ewma.Update(new TValue(DateTime.UtcNow, correctedPrice), isNew: false);
Assert.True(double.IsFinite(result.Value));
Assert.True(result.Value >= 0);
}
}
// ============ Known Value Validation ============
[Fact]
public void KnownValue_ConstantPrice_ZeroVolatility()
{
var ewma = new Ewma(10, false);
for (int i = 0; i < 30; i++)
{
ewma.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0));
}
Assert.Equal(0.0, ewma.Last.Value, 1e-10);
}
[Fact]
public void KnownValue_SimpleReturn()
{
// Verify log return calculation
// If price goes 100 → 101, log return = ln(101/100) ≈ 0.00995
var ewma = new Ewma(2, false);
ewma.Update(new TValue(DateTime.UtcNow, 100));
var result = ewma.Update(new TValue(DateTime.UtcNow.AddMinutes(1), 101));
double expectedLogReturn = Math.Log(101.0 / 100.0);
// With period=2, RMA of first squared return is just that return
// With bias correction at n=1, correction factor = 1 - 0.5 = 0.5
// First squared return initialized to sq_ret, then bias correction applied
// Volatility = sqrt(corrected variance)
Assert.True(result.Value > 0, "Volatility should be positive for price change");
Assert.True(result.Value < 0.05, "Volatility should be reasonable for 1% price change");
Assert.True(double.IsFinite(expectedLogReturn), "Log return should be finite");
}
[Fact]
public void KnownValue_SymmetricReturns()
{
// Volatility should be same for +10% and -10% returns (squared)
var ewmaUp = new Ewma(5, false);
var ewmaDown = new Ewma(5, false);
ewmaUp.Update(new TValue(DateTime.UtcNow, 100));
ewmaDown.Update(new TValue(DateTime.UtcNow, 100));
ewmaUp.Update(new TValue(DateTime.UtcNow.AddMinutes(1), 110)); // +10%
ewmaDown.Update(new TValue(DateTime.UtcNow.AddMinutes(1), 90)); // -10%
// Log returns: ln(1.1) ≈ 0.0953, ln(0.9) ≈ -0.1054
// Squared returns are slightly different due to log asymmetry
// But both should be positive volatility
Assert.True(ewmaUp.Last.Value > 0);
Assert.True(ewmaDown.Last.Value > 0);
}
// ============ Parameter Sensitivity Validation ============
[Fact]
public void ParameterSensitivity_ShorterPeriod_MoreResponsive()
{
var ewmaShort = new Ewma(5, false);
var ewmaLong = new Ewma(50, false);
// Build up history with low volatility
for (int i = 0; i < 60; i++)
{
ewmaShort.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0));
ewmaLong.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0));
}
double shortBefore = ewmaShort.Last.Value;
double longBefore = ewmaLong.Last.Value;
// Inject shock
ewmaShort.Update(new TValue(DateTime.UtcNow.AddMinutes(60), 120.0));
ewmaLong.Update(new TValue(DateTime.UtcNow.AddMinutes(60), 120.0));
double shortAfter = ewmaShort.Last.Value;
double longAfter = ewmaLong.Last.Value;
double shortIncrease = shortAfter - shortBefore;
double longIncrease = longAfter - longBefore;
Assert.True(shortIncrease > longIncrease,
"Shorter period should respond more strongly to shocks");
}
}
+353
View File
@@ -0,0 +1,353 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// EWMA: Exponentially Weighted Moving Average Volatility
/// </summary>
/// <remarks>
/// EWMA Volatility calculates volatility using an exponentially weighted moving average
/// of squared log returns. This approach gives more weight to recent observations while
/// still considering historical data, making it responsive to market changes.
///
/// Formula:
/// <c>r_t = ln(Close_t / Close_{t-1})</c>
/// <c>RMA_t = (RMA_{t-1} × (period - 1) + r²_t) / period</c>
/// <c>BiasCorrection = 1 - (1 - 1/period)^n</c>
/// <c>CorrectedVariance = RMA_t / BiasCorrection</c>
/// <c>EWMA = √(CorrectedVariance × AnnualPeriods)</c>
///
/// Key properties:
/// - Uses RMA (Running Moving Average) for exponential smoothing
/// - Includes bias correction for accurate early estimates
/// - Can be annualized or returned as periodic volatility
/// - More responsive than simple moving average approaches
/// </remarks>
[SkipLocalsInit]
public sealed class Ewma : AbstractBase
{
private readonly int _period;
private readonly bool _annualize;
private readonly int _annualPeriods;
private readonly double _decay;
private const double MinPrice = 1e-10;
private const double Epsilon = 1e-10;
[StructLayout(LayoutKind.Auto)]
private record struct State(
double RawRmaSqRet,
double BiasE,
double PrevClose,
double LastValid,
int Count);
private State _s;
private State _ps;
/// <summary>
/// Creates EWMA Volatility indicator with specified parameters.
/// </summary>
/// <param name="period">The period for EWMA calculation (must be > 0)</param>
/// <param name="annualize">Whether to annualize the volatility output (default: true)</param>
/// <param name="annualPeriods">Number of periods in a year for annualization (default: 252 for daily data)</param>
/// <exception cref="ArgumentException">Thrown when parameters are invalid</exception>
public Ewma(int period = 20, bool annualize = true, int annualPeriods = 252)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
if (annualize && annualPeriods <= 0)
{
throw new ArgumentException("Annual periods must be greater than 0 when annualizing", nameof(annualPeriods));
}
_period = period;
_annualize = annualize;
_annualPeriods = annualPeriods;
_decay = 1.0 - (1.0 / period);
Name = annualize ? $"Ewma({period},{annualPeriods})" : $"Ewma({period})";
WarmupPeriod = period;
_s = new State(0.0, 1.0, double.NaN, 0.0, 0);
_ps = _s;
}
/// <summary>
/// Creates EWMA Volatility indicator with specified source and parameters.
/// </summary>
public Ewma(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 completed the warmup period.
/// </summary>
public override bool IsHot => _s.Count >= _period;
/// <summary>
/// Period for EWMA calculation.
/// </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;
/// <inheritdoc/>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
double close = input.Value;
if (isNew)
{
_ps = _s;
}
else
{
_s = _ps;
}
var s = _s;
// Sanitize input - use state's LastValid for consistency
double lastValid = double.IsFinite(s.LastValid) && s.LastValid > 0 ? s.LastValid : 1.0;
if (!double.IsFinite(close) || close <= 0)
{
close = lastValid;
}
else if (isNew)
{
s.LastValid = close;
}
double safeClose = Math.Max(close, MinPrice);
double safePrevClose = double.IsFinite(s.PrevClose) && s.PrevClose > 0 ? s.PrevClose : safeClose;
// Calculate log return
double logReturn = 0.0;
if (safeClose > 0.0 && safePrevClose > 0.0)
{
logReturn = Math.Log(safeClose / safePrevClose);
}
double squaredReturn = logReturn * logReturn;
// RMA calculation: raw_rma_sq_ret = (raw_rma_sq_ret * (period - 1) + squaredReturn) / period
double rawRmaSqRet;
double biasE;
if (s.Count == 0)
{
// First value: initialize with squared return
rawRmaSqRet = squaredReturn;
biasE = _decay;
}
else
{
// RMA update: (prev * (period - 1) + current) / period
rawRmaSqRet = Math.FusedMultiplyAdd(s.RawRmaSqRet, _period - 1, squaredReturn) / _period;
// Update bias correction factor: e = (1 - alpha) * e_prev
biasE = _decay * s.BiasE;
}
// Bias correction: corrected = raw / (1 - e)
double biasCorrection = 1.0 - biasE;
double correctedRmaSqRet = biasCorrection > Epsilon ? rawRmaSqRet / biasCorrection : rawRmaSqRet;
// Ensure non-negative variance
double currentEwmaSqReturns = Math.Max(correctedRmaSqRet, 0.0);
// Calculate volatility
double volatility = Math.Sqrt(currentEwmaSqReturns);
// Annualize if requested
double result = _annualize ? volatility * Math.Sqrt(_annualPeriods) : volatility;
if (isNew)
{
s.RawRmaSqRet = rawRmaSqRet;
s.BiasE = biasE;
s.PrevClose = safeClose;
s.Count++;
_s = s;
}
if (!double.IsFinite(result))
{
result = 0.0;
}
Last = new TValue(input.Time, result);
PubEvent(Last, isNew);
return Last;
}
/// <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 to match final position
for (int i = 0; i < len; i++)
{
Update(new TValue(source.Times[i], source.Values[i]), isNew: true);
}
return new TSeries(t, v);
}
/// <inheritdoc/>
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
for (int i = 0; i < source.Length; i++)
{
Update(new TValue(DateTime.UtcNow, source[i]), isNew: true);
}
}
/// <inheritdoc/>
public override void Reset()
{
_s = new State(0.0, 1.0, double.NaN, 0.0, 0);
_ps = _s;
Last = default;
}
/// <summary>
/// Calculates EWMA Volatility for entire series.
/// </summary>
public static TSeries Calculate(TSeries source, int period = 20, bool annualize = true, int annualPeriods = 252)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
if (annualize && annualPeriods <= 0)
{
throw new ArgumentException("Annual periods must be greater than 0 when annualizing", nameof(annualPeriods));
}
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
Batch(source.Values, vSpan, period, annualize, annualPeriods);
source.Times.CopyTo(tSpan);
return new TSeries(t, v);
}
/// <summary>
/// Batch EWMA Volatility calculation.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period = 20, bool annualize = true, int annualPeriods = 252)
{
if (source.Length != output.Length)
{
throw new ArgumentException("Source and output must have the same length", nameof(output));
}
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
if (annualize && annualPeriods <= 0)
{
throw new ArgumentException("Annual periods must be greater than 0 when annualizing", nameof(annualPeriods));
}
int len = source.Length;
if (len == 0)
{
return;
}
double alpha = 1.0 / period;
double decay = 1.0 - alpha;
double annualFactor = annualize ? Math.Sqrt(annualPeriods) : 1.0;
double rawRmaSqRet = 0.0;
double biasE = 1.0;
double prevClose = double.NaN;
double lastValidClose = 1.0;
for (int i = 0; i < len; i++)
{
double close = source[i];
// Sanitize input
if (!double.IsFinite(close) || close <= 0)
{
close = lastValidClose;
}
else
{
lastValidClose = close;
}
double safeClose = Math.Max(close, MinPrice);
double safePrevClose = double.IsFinite(prevClose) && prevClose > 0 ? prevClose : safeClose;
// Calculate log return
double logReturn = 0.0;
if (safeClose > 0.0 && safePrevClose > 0.0)
{
logReturn = Math.Log(safeClose / safePrevClose);
}
double squaredReturn = logReturn * logReturn;
// RMA calculation
if (i == 0)
{
rawRmaSqRet = squaredReturn;
biasE = decay;
}
else
{
rawRmaSqRet = Math.FusedMultiplyAdd(rawRmaSqRet, period - 1, squaredReturn) / period;
biasE = decay * biasE;
}
// Bias correction
double biasCorrection = 1.0 - biasE;
double correctedRmaSqRet = biasCorrection > Epsilon ? rawRmaSqRet / biasCorrection : rawRmaSqRet;
// Calculate volatility
double currentEwmaSqReturns = Math.Max(correctedRmaSqRet, 0.0);
double volatility = Math.Sqrt(currentEwmaSqReturns);
double result = volatility * annualFactor;
prevClose = safeClose;
output[i] = double.IsFinite(result) ? result : 0.0;
}
}
}
+168
View File
@@ -0,0 +1,168 @@
# EWMA: Exponentially Weighted Moving Average Volatility
> "The past doesn't repeat itself, but it does rhyme—and EWMA captures the rhythm of volatility with exponential memory."
EWMA Volatility calculates market volatility using an exponentially weighted moving average of squared log returns with bias correction. Unlike simple historical volatility that weights all observations equally, EWMA gives more weight to recent observations while still considering historical data, making it more responsive to current market conditions.
## Historical Context
Exponentially Weighted Moving Average volatility emerged from J.P. Morgan's RiskMetrics methodology in the 1990s. The approach addressed a key limitation of simple historical volatility: equal weighting of all past observations regardless of age. Financial practitioners recognized that recent market movements often provide more relevant information about current risk than distant historical data.
The original RiskMetrics Technical Document (1996) proposed a "decay factor" (λ) of 0.94 for daily data, meaning approximately 6% weight goes to the most recent observation. This implementation uses an equivalent RMA (Running Moving Average) formulation with period-based smoothing plus bias correction to address the initialization problem that affects early estimates.
## Architecture & Physics
### 1. Log Return Calculation
The foundation uses continuously compounded returns:
$$
r_t = \ln\left(\frac{P_t}{P_{t-1}}\right)
$$
Log returns are preferred over simple returns because:
- They are additive across time periods
- They are symmetric (±10% moves have similar magnitude)
- They approximate percentage changes for small movements
### 2. RMA Smoothing of Squared Returns
The squared returns are smoothed using RMA (Running Moving Average):
$$
\text{RMA}_t = \frac{\text{RMA}_{t-1} \times (period - 1) + r_t^2}{period}
$$
This is equivalent to an EMA with smoothing factor $\alpha = 1/period$:
$$
\text{RMA}_t = (1 - \alpha) \times \text{RMA}_{t-1} + \alpha \times r_t^2
$$
### 3. Bias Correction
The bias correction factor addresses the initialization problem where early estimates are biased toward zero:
$$
e_t = (1 - \alpha)^t
$$
$$
\text{CorrectedVariance}_t = \frac{\text{RMA}_t}{1 - e_t}
$$
As $t \to \infty$, the correction factor approaches 1, having negligible effect on mature estimates.
### 4. Volatility Output
The volatility is the square root of the corrected variance:
$$
\sigma_t = \sqrt{\text{CorrectedVariance}_t}
$$
With optional annualization:
$$
\sigma_{annual} = \sigma_t \times \sqrt{T}
$$
where $T$ is the number of periods per year (252 for daily, 52 for weekly, 12 for monthly).
## Mathematical Foundation
### Decay Factor Relationship
The period parameter maps to the traditional RiskMetrics decay factor:
$$
\lambda = \frac{period - 1}{period} = 1 - \frac{1}{period}
$$
For period = 20: $\lambda = 0.95$ (5% weight on new observation)
For period = 10: $\lambda = 0.90$ (10% weight on new observation)
### Effective Window
The effective window (where ~95% of weight is concentrated) is approximately:
$$
\text{EffectiveWindow} \approx \frac{2}{\alpha} = 2 \times period
$$
### Bias Correction Derivation
The uncorrected RMA is a biased estimator because:
$$
E[\text{RMA}_t] = E[r^2] \times (1 - (1-\alpha)^t)
$$
Dividing by $(1 - (1-\alpha)^t)$ produces an unbiased estimator.
## Performance Profile
### Operation Count (Streaming Mode, Scalar)
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| LOG | 1 | 50 | 50 |
| DIV | 2 | 15 | 30 |
| MUL | 3 | 3 | 9 |
| FMA | 1 | 4 | 4 |
| SQRT | 1 | 15 | 15 |
| ADD/SUB | 2 | 1 | 2 |
| CMP | 3 | 1 | 3 |
| **Total** | **13** | — | **~113 cycles** |
The LOG operation dominates the cost. For batch processing, the logarithm is unavoidable due to the sequential dependency on price ratios.
### Batch Mode (SIMD Applicability)
EWMA has limited SIMD potential due to:
1. **Sequential dependency**: Each RMA value depends on the previous
2. **Log operation**: Sequential price ratio requirement
The batch implementation maintains the same algorithm as streaming for consistency.
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 9/10 | Bias correction ensures accurate early estimates |
| **Timeliness** | 8/10 | Exponential weighting responds quickly to shocks |
| **Smoothness** | 7/10 | Smoother than simple historical volatility |
| **Simplicity** | 9/10 | Straightforward implementation |
| **Robustness** | 8/10 | Handles edge cases well |
## Validation
| Library | Status | Notes |
| :--- | :---: | :--- |
| **TA-Lib** | N/A | No direct EWMA volatility function |
| **Skender** | N/A | No EWMA volatility indicator |
| **Tulip** | N/A | No EWMA volatility indicator |
| **Ooples** | N/A | No EWMA volatility indicator |
| **PineScript** | ✅ | Reference implementation matches |
Note: This implementation is based on the PineScript reference at `ewma.pine`. The mathematical properties and mode consistency are validated through comprehensive unit tests.
## Common Pitfalls
1. **Annualization Confusion**: The `annualPeriods` parameter should match your data frequency. Use 252 for daily bars, 52 for weekly, 12 for monthly. Using incorrect values produces misleading annualized volatility.
2. **Period Selection**: Shorter periods (e.g., 10) respond faster to volatility changes but are noisier. Longer periods (e.g., 50) are smoother but slower to react. The classic RiskMetrics λ=0.94 corresponds to period≈17.
3. **First Value Interpretation**: The first output is always 0 (no return calculated yet). The second output may show high volatility if there's a large price change from the first bar.
4. **Log Return Assumptions**: EWMA assumes returns are approximately normally distributed. During extreme market events (fat tails), volatility may be underestimated.
5. **Bar Correction (isNew=false)**: When correcting the current bar's price, the indicator properly rolls back state. Multiple corrections within the same bar are handled correctly.
6. **Invalid Inputs**: NaN, Infinity, zero, and negative prices are replaced with the last valid price to maintain calculation continuity.
## References
- J.P. Morgan/Reuters. (1996). "RiskMetrics Technical Document." Fourth Edition.
- Bollerslev, T. (1986). "Generalized Autoregressive Conditional Heteroskedasticity." Journal of Econometrics.
- Hull, J. (2018). "Options, Futures, and Other Derivatives." Chapter on Volatility Estimation.