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
+284
View File
@@ -0,0 +1,284 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public class CviIndicatorTests
{
[Fact]
public void CviIndicator_Constructor_SetsDefaults()
{
var indicator = new CviIndicator();
Assert.Equal(10, indicator.RocLength);
Assert.Equal(10, indicator.SmoothLength);
Assert.True(indicator.ShowColdValues);
Assert.Equal("CVI - Chaikin's Volatility", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void CviIndicator_ShortName_IncludesParameters()
{
var indicator = new CviIndicator { RocLength = 14, SmoothLength = 20 };
Assert.Contains("CVI", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("14", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("20", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void CviIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new CviIndicator();
Assert.Equal(0, CviIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void CviIndicator_Initialize_CreatesInternalCvi()
{
var indicator = new CviIndicator();
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void CviIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new CviIndicator { RocLength = 5, SmoothLength = 5 };
indicator.Initialize();
// Add historical data with varying high-low ranges
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
double basePrice = 100 + i;
double range = 2 + (i % 5); // Varying ranges
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + range, basePrice - range, basePrice + 1, 1000);
// Process update for each bar to simulate history loading
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
// Line series should have a value
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val));
}
[Fact]
public void CviIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new CviIndicator { RocLength = 5, SmoothLength = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
double basePrice = 100 + i;
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 2, 1000);
}
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Add new bar with larger range
indicator.HistoricalData.AddBar(now.AddMinutes(30), 120, 135, 105, 125, 1500);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void CviIndicator_DifferentRocLengths_Work()
{
int[] rocLengths = { 5, 10, 14, 20 };
foreach (var rocLength in rocLengths)
{
var indicator = new CviIndicator { RocLength = rocLength, SmoothLength = 10 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 50; i++)
{
double basePrice = 100 + i;
double range = 3 + (i % 4);
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + range, basePrice - range, basePrice + 1, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val), $"ROC length {rocLength} should produce finite value");
}
}
[Fact]
public void CviIndicator_DifferentSmoothLengths_Work()
{
int[] smoothLengths = { 5, 10, 14, 20 };
foreach (var smoothLength in smoothLengths)
{
var indicator = new CviIndicator { RocLength = 10, SmoothLength = smoothLength };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 50; i++)
{
double basePrice = 100 + i;
double range = 3 + (i % 4);
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + range, basePrice - range, basePrice + 1, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val), $"Smooth length {smoothLength} should produce finite value");
}
}
[Fact]
public void CviIndicator_RocLength_CanBeChanged()
{
var indicator = new CviIndicator();
Assert.Equal(10, indicator.RocLength);
indicator.RocLength = 14;
Assert.Equal(14, indicator.RocLength);
indicator.RocLength = 20;
Assert.Equal(20, indicator.RocLength);
}
[Fact]
public void CviIndicator_SmoothLength_CanBeChanged()
{
var indicator = new CviIndicator();
Assert.Equal(10, indicator.SmoothLength);
indicator.SmoothLength = 14;
Assert.Equal(14, indicator.SmoothLength);
indicator.SmoothLength = 20;
Assert.Equal(20, indicator.SmoothLength);
}
[Fact]
public void CviIndicator_ShowColdValues_CanBeToggled()
{
var indicator = new CviIndicator();
Assert.True(indicator.ShowColdValues);
indicator.ShowColdValues = false;
Assert.False(indicator.ShowColdValues);
indicator.ShowColdValues = true;
Assert.True(indicator.ShowColdValues);
}
[Fact]
public void CviIndicator_SourceCodeLink_IsValid()
{
var indicator = new CviIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Cvi.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void CviIndicator_ExpandingVolatility_ProducesPositiveValues()
{
var indicator = new CviIndicator { RocLength = 5, SmoothLength = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
// First 20 bars: small range
for (int i = 0; i < 20; i++)
{
double basePrice = 100;
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 1, basePrice - 1, basePrice, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
// Next 15 bars: expanding range
for (int i = 20; i < 35; i++)
{
double basePrice = 100;
double range = 1 + (i - 20) * 0.5; // Gradually increasing range
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + range, basePrice - range, basePrice, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val), "Expanding volatility should produce finite value");
// With expanding ranges, CVI should trend positive
Assert.True(val > 0, "Expanding volatility should produce positive CVI");
}
[Fact]
public void CviIndicator_ContractingVolatility_ProducesNegativeValues()
{
var indicator = new CviIndicator { RocLength = 5, SmoothLength = 5 };
indicator.Initialize();
var now = DateTime.UtcNow;
// First 20 bars: large range
for (int i = 0; i < 20; i++)
{
double basePrice = 100;
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 10, basePrice - 10, basePrice, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
// Next 15 bars: contracting range
for (int i = 20; i < 35; i++)
{
double basePrice = 100;
double range = Math.Max(1, 10 - (i - 20) * 0.5); // Gradually decreasing range
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + range, basePrice - range, basePrice, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val), "Contracting volatility should produce finite value");
// With contracting ranges, CVI should trend negative
Assert.True(val < 0, "Contracting volatility should produce negative CVI");
}
[Fact]
public void CviIndicator_UsesHighLowRange()
{
var indicator1 = new CviIndicator { RocLength = 5, SmoothLength = 5 };
var indicator2 = new CviIndicator { RocLength = 5, SmoothLength = 5 };
indicator1.Initialize();
indicator2.Initialize();
var now = DateTime.UtcNow;
// Same OHLC structure but different ranges
for (int i = 0; i < 30; i++)
{
// Indicator 1: narrow range
indicator1.HistoricalData.AddBar(now.AddMinutes(i), 100, 102, 98, 101, 1000);
indicator1.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Indicator 2: wide range (same open/close, different high/low)
indicator2.HistoricalData.AddBar(now.AddMinutes(i), 100, 110, 90, 101, 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));
// With constant but different ranges, the absolute values may differ
// but both should be close to 0 (no rate of change)
}
}
+52
View File
@@ -0,0 +1,52 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class CviIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("ROC Length", sortIndex: 1, 1, 1000, 1, 0)]
public int RocLength { get; set; } = 10;
[InputParameter("Smooth Length", sortIndex: 2, 1, 1000, 1, 0)]
public int SmoothLength { get; set; } = 10;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Cvi _cvi = null!;
private readonly LineSeries _series;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"CVI {RocLength},{SmoothLength}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/volatility/cvi/Cvi.Quantower.cs";
public CviIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "CVI - Chaikin's Volatility";
Description = "Chaikin's Volatility measures the rate of change of the EMA-smoothed high-low range, identifying periods of expanding or contracting volatility";
_series = new LineSeries(name: "CVI", color: IndicatorExtensions.Volatility, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
protected override void OnInit()
{
_cvi = new Cvi(RocLength, SmoothLength);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TBar bar = this.GetInputBar(args);
TValue result = _cvi.Update(bar, isNew: args.IsNewBar());
_series.SetValue(result.Value, _cvi.IsHot, ShowColdValues);
}
}
+500
View File
@@ -0,0 +1,500 @@
namespace QuanTAlib.Tests;
using Xunit;
public class CviTests
{
private const double Tolerance = 1e-10;
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 Cvi(0, 10));
Assert.Throws<ArgumentException>(() => new Cvi(-1, 10));
Assert.Throws<ArgumentException>(() => new Cvi(10, 0));
Assert.Throws<ArgumentException>(() => new Cvi(10, -1));
var valid = new Cvi(10, 10);
Assert.Equal(10, valid.RocLength);
Assert.Equal(10, valid.SmoothLength);
}
[Fact]
public void WarmupPeriod_IsCorrect()
{
var cvi = new Cvi(10, 10);
Assert.Equal(20, cvi.WarmupPeriod); // smoothLength + rocLength
Assert.True(cvi.WarmupPeriod > 0);
}
[Fact]
public void Properties_Accessible()
{
var cvi = new Cvi(14, 10);
Assert.Equal(14, cvi.RocLength);
Assert.Equal(10, cvi.SmoothLength);
Assert.Equal("Cvi(14,10)", cvi.Name);
}
[Fact]
public void BasicCalculation_DoesNotCrash()
{
var cvi = new Cvi(5, 5);
var bars = GenerateTestData(100);
for (int i = 0; i < bars.Count; i++)
{
var result = cvi.Update(bars[i]);
Assert.True(double.IsFinite(result.Value));
}
}
[Fact]
public void Calc_ReturnsValue()
{
var cvi = new Cvi(10, 10);
var bars = GenerateTestData(30);
for (int i = 0; i < bars.Count; i++)
{
var result = cvi.Update(bars[i]);
Assert.True(double.IsFinite(result.Value));
}
Assert.True(cvi.IsHot);
}
[Fact]
public void Calc_IsNew_AcceptsParameter()
{
var cvi = new Cvi(10, 10);
var bars = GenerateTestData(5);
var result1 = cvi.Update(bars[0], isNew: true);
var result2 = cvi.Update(bars[1], isNew: true);
var result3 = cvi.Update(bars[2], 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 cvi = new Cvi(5, 5);
var bars = GenerateTestData(20);
for (int i = 0; i < 15; i++)
{
cvi.Update(bars[i], isNew: true);
}
var baseline = cvi.Update(bars[15], isNew: true);
// Create a bar with very different High-Low range
var modifiedBar = new TBar(
bars[15].Time,
bars[15].Open,
bars[15].High + 10, // Increase high
bars[15].Low - 10, // Decrease low
bars[15].Close,
bars[15].Volume
);
var updated = cvi.Update(modifiedBar, isNew: false);
Assert.NotEqual(baseline.Value, updated.Value);
}
[Fact]
public void IsHot_BecomesTrueAfterWarmup()
{
int rocLength = 10;
int smoothLength = 10;
var cvi = new Cvi(rocLength, smoothLength);
var bars = GenerateTestData(30);
int warmup = smoothLength + rocLength; // 20
for (int i = 0; i < warmup - 1; i++)
{
cvi.Update(bars[i]);
Assert.False(cvi.IsHot);
}
cvi.Update(bars[warmup - 1]);
Assert.True(cvi.IsHot);
}
[Fact]
public void Reset_Works()
{
var cvi = new Cvi(10, 10);
var bars = GenerateTestData(30);
for (int i = 0; i < bars.Count; i++)
{
cvi.Update(bars[i]);
}
Assert.True(cvi.IsHot);
cvi.Reset();
Assert.False(cvi.IsHot);
}
[Fact]
public void SingleValue_ReturnsZero()
{
var cvi = new Cvi(5, 5);
var bars = GenerateTestData(1);
var result = cvi.Update(bars[0]);
// First value has no ROC data yet, should be 0
Assert.Equal(0.0, result.Value);
}
[Fact]
public void IterativeCorrections_RestoreToOriginalState()
{
var cvi = new Cvi(10, 10);
var bars = GenerateTestData(50);
TValue lastValue = default;
for (int i = 0; i < bars.Count; i++)
{
lastValue = cvi.Update(bars[i], isNew: true);
}
double originalValue = lastValue.Value;
// Apply a correction with very different range
var modifiedBar = new TBar(
bars[bars.Count - 1].Time,
100, 200, 50, 150, 1000
);
var correctedValue = cvi.Update(modifiedBar, isNew: false);
Assert.NotEqual(originalValue, correctedValue.Value);
// Restore original value
var restoredValue = cvi.Update(bars[bars.Count - 1], isNew: false);
Assert.Equal(originalValue, restoredValue.Value, 1e-9);
}
[Fact]
public void IsNew_Consistency()
{
var cvi = new Cvi(10, 10);
var bars = GenerateTestData(25);
for (int i = 0; i < 20; i++)
{
cvi.Update(bars[i], isNew: true);
}
var result1 = cvi.Update(bars[20], isNew: true);
_ = cvi.Update(bars[21], isNew: false);
var result3 = cvi.Update(bars[20], isNew: false);
Assert.Equal(result1.Value, result3.Value, Tolerance);
}
[Fact]
public void NaN_Input_UsesLastValidValue()
{
var cvi = new Cvi(5, 5);
var bars = GenerateTestData(20);
for (int i = 0; i < 15; i++)
{
cvi.Update(bars[i]);
}
// Update with NaN value (treated as pre-calculated range)
var resultNan = cvi.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsFinite(resultNan.Value));
}
[Fact]
public void Infinity_Input_UsesLastValidValue()
{
var cvi = new Cvi(5, 5);
var bars = GenerateTestData(20);
for (int i = 0; i < 15; i++)
{
cvi.Update(bars[i]);
}
var resultInf = cvi.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
Assert.True(double.IsFinite(resultInf.Value));
}
[Fact]
public void NegativeRange_UsesLastValidValue()
{
var cvi = new Cvi(5, 5);
var bars = GenerateTestData(20);
for (int i = 0; i < 15; i++)
{
cvi.Update(bars[i]);
}
// Negative range is invalid for High-Low
var resultNeg = cvi.Update(new TValue(DateTime.UtcNow, -5.0));
Assert.True(double.IsFinite(resultNeg.Value));
}
[Fact]
public void LargeDataset_Performance()
{
var cvi = new Cvi(14, 10);
var bars = GenerateTestData(5000);
for (int i = 0; i < bars.Count; i++)
{
var result = cvi.Update(bars[i]);
Assert.True(double.IsFinite(result.Value));
}
}
[Fact]
public void TBarSeries_Update_Works()
{
int rocLength = 10;
int smoothLength = 10;
var cvi = new Cvi(rocLength, smoothLength);
var bars = GenerateTestData(100);
var result = cvi.Update(bars);
Assert.Equal(bars.Count, result.Count);
Assert.True(double.IsFinite(result[result.Count - 1].Value));
}
[Fact]
public void TSeries_Update_MatchesStreaming()
{
int rocLength = 10;
int smoothLength = 10;
var cviStream = new Cvi(rocLength, smoothLength);
var cviBatch = new Cvi(rocLength, smoothLength);
var bars = GenerateTestData(100);
// Streaming mode
for (int i = 0; i < bars.Count; i++)
{
cviStream.Update(bars[i]);
}
// Batch mode with TBarSeries
var result = cviBatch.Update(bars);
Assert.Equal(cviStream.Last.Value, result[result.Count - 1].Value, 1e-9);
}
[Fact]
public void BatchCalc_MatchesIterativeCalc()
{
var cvi = new Cvi(10, 10);
var bars = GenerateTestData(200);
// Streaming
for (int i = 0; i < bars.Count; i++)
{
cvi.Update(bars[i]);
}
var iterativeResult = cvi.Last.Value;
// Batch via static method
var batchResult = Cvi.Calculate(bars, 10, 10);
Assert.Equal(iterativeResult, batchResult[batchResult.Count - 1].Value, 1e-8);
}
[Fact]
public void StaticBatch_Works()
{
var bars = GenerateTestData(100);
var result = Cvi.Calculate(bars, 14, 10);
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>(() => Cvi.Calculate(ts, 0, 10));
Assert.Throws<ArgumentException>(() => Cvi.Calculate(ts, -1, 10));
Assert.Throws<ArgumentException>(() => Cvi.Calculate(ts, 10, 0));
Assert.Throws<ArgumentException>(() => Cvi.Calculate(ts, 10, -1));
}
[Fact]
public void Batch_NaN_Safe()
{
var values = new double[] { 1.0, 1.2, 1.1, double.NaN, 1.3, 1.4 };
var output = new double[values.Length];
Cvi.Batch(values, output, 2, 2);
Assert.True(output.Length == 6);
for (int i = 0; i < output.Length; i++)
{
Assert.True(double.IsFinite(output[i]));
}
}
[Fact]
public void ConstantRange_ZeroVolatility()
{
var cvi = new Cvi(10, 10);
// Feed constant high-low range
for (int i = 0; i < 30; i++)
{
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 105.0, 95.0, 102.0, 1000.0 // Constant 10-point range
);
cvi.Update(bar);
}
// Constant range should result in zero or near-zero CVI (no rate of change)
Assert.True(Math.Abs(cvi.Last.Value) < 1.0, "Constant range should have near-zero CVI");
}
[Fact]
public void ExpandingVolatility_PositiveValue()
{
var cvi = new Cvi(5, 5);
// Start with small range, expand over time
for (int i = 0; i < 20; i++)
{
double range = 5 + i * 0.5; // Expanding range
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 100.0 + range / 2, 100.0 - range / 2, 100.0, 1000.0
);
cvi.Update(bar);
}
// Expanding volatility should produce positive CVI
Assert.True(cvi.Last.Value > 0, "Expanding volatility should produce positive CVI");
}
[Fact]
public void ContractingVolatility_NegativeValue()
{
var cvi = new Cvi(5, 5);
// Start with large range, contract over time
for (int i = 0; i < 20; i++)
{
double range = 20 - i * 0.5; // Contracting range
if (range < 1)
{
range = 1;
}
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 100.0 + range / 2, 100.0 - range / 2, 100.0, 1000.0
);
cvi.Update(bar);
}
// Contracting volatility should produce negative CVI
Assert.True(cvi.Last.Value < 0, "Contracting volatility should produce negative CVI");
}
[Fact]
public void DifferentParameters_ProduceDistinctValues()
{
var bars = GenerateTestData(50);
var cvi1 = new Cvi(10, 10);
var cvi2 = new Cvi(14, 10);
var cvi3 = new Cvi(10, 14);
for (int i = 0; i < bars.Count; i++)
{
cvi1.Update(bars[i]);
cvi2.Update(bars[i]);
cvi3.Update(bars[i]);
}
Assert.True(double.IsFinite(cvi1.Last.Value));
Assert.True(double.IsFinite(cvi2.Last.Value));
Assert.True(double.IsFinite(cvi3.Last.Value));
// Different parameters should produce different values
Assert.NotEqual(cvi1.Last.Value, cvi2.Last.Value);
}
[Fact]
public void TValueUpdate_TreatsValueAsRange()
{
var cvi = new Cvi(5, 5);
// Feed pre-calculated range values via TValue
for (int i = 0; i < 20; i++)
{
var result = cvi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 5.0 + i * 0.1));
Assert.True(double.IsFinite(result.Value));
}
Assert.True(cvi.IsHot);
}
[Fact]
public void Chainability_Works()
{
var cvi = new Cvi(10, 10);
var sma = new Sma(5);
var bars = GenerateTestData(100);
for (int i = 0; i < bars.Count; i++)
{
var cviResult = cvi.Update(bars[i]);
sma.Update(cviResult);
}
Assert.True(sma.IsHot);
Assert.True(double.IsFinite(sma.Last.Value));
}
[Fact]
public void SpanBatch_MatchesOutputLength()
{
var values = new double[] { 1.0, 1.2, 1.1, 1.3, 1.4, 1.2, 1.5, 1.3, 1.6, 1.4 };
var output = new double[values.Length];
Cvi.Batch(values, output, 3, 3);
Assert.Equal(values.Length, output.Length);
}
[Fact]
public void SpanBatch_ValidatesArguments()
{
var source = new double[] { 1.0, 1.2, 1.1 };
var outputShort = new double[2];
var outputCorrect = new double[3];
Assert.Throws<ArgumentException>(() => Cvi.Batch(source, outputShort, 2, 2));
Assert.Throws<ArgumentException>(() => Cvi.Batch(source, outputCorrect, 0, 2));
Assert.Throws<ArgumentException>(() => Cvi.Batch(source, outputCorrect, 2, 0));
}
}
+518
View File
@@ -0,0 +1,518 @@
namespace QuanTAlib.Test;
using Xunit;
/// <summary>
/// Validation tests for CVI (Chaikin's Volatility).
/// CVI measures the rate of change of EMA-smoothed high-low range.
/// Formula: CVI = ((EMA_t - EMA_{t-rocLength}) / EMA_{t-rocLength}) × 100
/// where EMA is applied to (High - Low) range.
/// </summary>
public class CviValidationTests
{
private static TBarSeries GenerateTestData(int count = 100)
{
var gbm = new GBM(seed: 42);
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
}
// === Mathematical Validation ===
/// <summary>
/// Validates the EMA alpha formula: α = 2 / (smoothLength + 1)
/// </summary>
[Theory]
[InlineData(10, 0.181818181818182)] // 2/(10+1) = 0.1818...
[InlineData(14, 0.133333333333333)] // 2/(14+1) = 0.1333...
[InlineData(20, 0.095238095238095)] // 2/(20+1) = 0.0952...
public void Cvi_EmaAlpha_IsCorrect(int smoothLength, double expectedAlpha)
{
double alpha = 2.0 / (smoothLength + 1);
Assert.Equal(expectedAlpha, alpha, 10);
}
/// <summary>
/// Validates ROC formula: ((current - prior) / prior) × 100
/// </summary>
[Fact]
public void Cvi_RocFormula_IsCorrect()
{
// Manual ROC calculation
double currentEma = 10.0;
double priorEma = 8.0;
double expectedRoc = ((currentEma - priorEma) / priorEma) * 100.0;
Assert.Equal(25.0, expectedRoc, 10); // (10-8)/8 * 100 = 25%
}
/// <summary>
/// Validates that constant high-low range produces zero CVI after warmup.
/// </summary>
[Fact]
public void Cvi_ConstantRange_ProducesZeroCvi()
{
var cvi = new Cvi(10, 10);
// Feed constant range bars
for (int i = 0; i < 30; i++)
{
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 105.0, 95.0, 102.0, 1000.0 // Constant 10-point range
);
cvi.Update(bar);
}
// Constant range means EMA_t = EMA_{t-rocLength}, so ROC = 0
Assert.Equal(0.0, cvi.Last.Value, 5);
}
/// <summary>
/// Validates expanding range produces positive CVI.
/// </summary>
[Fact]
public void Cvi_ExpandingRange_ProducesPositiveCvi()
{
var cvi = new Cvi(5, 5);
// Gradually expanding range
for (int i = 0; i < 20; i++)
{
double range = 5 + i * 0.5; // Expanding from 5 to 14.5
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 100.0 + range / 2, 100.0 - range / 2, 100.0, 1000.0
);
cvi.Update(bar);
}
// Expanding range should produce positive CVI (EMA increasing)
Assert.True(cvi.Last.Value > 0, "Expanding range should produce positive CVI");
}
/// <summary>
/// Validates contracting range produces negative CVI.
/// </summary>
[Fact]
public void Cvi_ContractingRange_ProducesNegativeCvi()
{
var cvi = new Cvi(5, 5);
// Gradually contracting range
for (int i = 0; i < 20; i++)
{
double range = 20 - i * 0.5; // Contracting from 20 to 10.5
if (range < 1)
{
range = 1;
}
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 100.0 + range / 2, 100.0 - range / 2, 100.0, 1000.0
);
cvi.Update(bar);
}
// Contracting range should produce negative CVI (EMA decreasing)
Assert.True(cvi.Last.Value < 0, "Contracting range should produce negative CVI");
}
/// <summary>
/// Validates manual CVI calculation matches implementation.
/// </summary>
[Fact]
public void Cvi_ManualCalculation_MatchesImplementation()
{
int rocLength = 3;
int smoothLength = 3;
double alpha = 2.0 / (smoothLength + 1); // 0.5
// Fixed range values
double[] ranges = { 10.0, 12.0, 11.0, 13.0, 15.0, 14.0, 16.0, 18.0, 17.0, 19.0 };
// Calculate EMA manually
double[] emas = new double[ranges.Length];
emas[0] = ranges[0];
for (int i = 1; i < ranges.Length; i++)
{
emas[i] = (ranges[i] - emas[i - 1]) * alpha + emas[i - 1];
}
// Calculate ROC for last point
int lastIdx = ranges.Length - 1;
double oldEma = emas[lastIdx - rocLength];
double currentEma = emas[lastIdx];
double expectedCvi = ((currentEma - oldEma) / oldEma) * 100.0;
// Calculate using indicator
var cvi = new Cvi(rocLength, smoothLength);
for (int i = 0; i < ranges.Length; i++)
{
cvi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), ranges[i]));
}
Assert.Equal(expectedCvi, cvi.Last.Value, 8);
}
/// <summary>
/// Validates EMA smoothing property: EMA responds to recent values more.
/// </summary>
[Fact]
public void Cvi_EmaSmoothingProperty_RecentValuesWeightedMore()
{
var cvi = new Cvi(5, 5);
// Feed stable values then spike
for (int i = 0; i < 15; i++)
{
cvi.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 10.0));
}
double preSpikeValue = cvi.Last.Value;
// Single spike
cvi.Update(new TValue(DateTime.UtcNow.AddMinutes(15), 20.0));
double postSpikeValue = cvi.Last.Value;
// EMA should respond to spike (increasing CVI since range doubled)
Assert.True(postSpikeValue > preSpikeValue,
"EMA should respond to recent value changes");
}
// === Consistency Tests ===
/// <summary>
/// Validates streaming and batch produce identical results.
/// </summary>
[Fact]
public void Cvi_StreamingMatchesBatch()
{
var bars = GenerateTestData(100);
// Streaming calculation
var streamingCvi = new Cvi(10, 10);
for (int i = 0; i < bars.Count; i++)
{
streamingCvi.Update(bars[i]);
}
// Batch calculation
var batchResult = Cvi.Calculate(bars, 10, 10);
// Compare last values
Assert.Equal(batchResult.Last.Value, streamingCvi.Last.Value, 8);
}
/// <summary>
/// Validates TBarSeries input matches TBar streaming.
/// </summary>
[Fact]
public void Cvi_TBarSeriesInput_MatchesStreaming()
{
var bars = GenerateTestData(100);
// Streaming
var streamingCvi = new Cvi(10, 10);
for (int i = 0; i < bars.Count; i++)
{
streamingCvi.Update(bars[i]);
}
// TBarSeries batch
var batchCvi = new Cvi(10, 10);
var batchResult = batchCvi.Update(bars);
Assert.Equal(batchResult.Last.Value, streamingCvi.Last.Value, 10);
}
/// <summary>
/// Validates Span batch matches streaming.
/// </summary>
[Fact]
public void Cvi_SpanBatch_MatchesStreaming()
{
var bars = GenerateTestData(100);
// Extract ranges from bars
var ranges = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
ranges[i] = bars[i].High - bars[i].Low;
}
// Streaming
var streamingCvi = new Cvi(10, 10);
for (int i = 0; i < bars.Count; i++)
{
streamingCvi.Update(new TValue(bars.Times[i], ranges[i]));
}
// Span batch
var output = new double[ranges.Length];
Cvi.Batch(ranges, output, 10, 10);
Assert.Equal(output[^1], streamingCvi.Last.Value, 10);
}
// === Parameter Sensitivity ===
/// <summary>
/// Validates shorter rocLength produces more volatile CVI.
/// </summary>
[Fact]
public void Cvi_ShorterRocLength_MoreVolatile()
{
var bars = GenerateTestData(100);
var cviShort = new Cvi(5, 10); // rocLength = 5
var cviLong = new Cvi(20, 10); // rocLength = 20
var shortResults = new List<double>();
var longResults = new List<double>();
for (int i = 0; i < bars.Count; i++)
{
cviShort.Update(bars[i]);
cviLong.Update(bars[i]);
if (cviShort.IsHot && cviLong.IsHot)
{
shortResults.Add(cviShort.Last.Value);
longResults.Add(cviLong.Last.Value);
}
}
// Shorter rocLength should generally produce more volatile CVI values
// (comparing values over fewer periods)
Assert.True(shortResults.Count > 0, "Should have hot results");
}
/// <summary>
/// Validates shorter smoothLength produces faster response.
/// </summary>
[Fact]
public void Cvi_ShorterSmoothLength_FasterResponse()
{
var cviShort = new Cvi(10, 5); // smoothLength = 5
var cviLong = new Cvi(10, 20); // smoothLength = 20
// Feed stable values
for (int i = 0; i < 30; i++)
{
cviShort.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 10.0));
cviLong.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 10.0));
}
double preShortValue = cviShort.Last.Value;
double preLongValue = cviLong.Last.Value;
// Spike in range
cviShort.Update(new TValue(DateTime.UtcNow.AddMinutes(30), 20.0));
cviLong.Update(new TValue(DateTime.UtcNow.AddMinutes(30), 20.0));
double changeShort = Math.Abs(cviShort.Last.Value - preShortValue);
double changeLong = Math.Abs(cviLong.Last.Value - preLongValue);
// Shorter smoothLength should show larger immediate change
Assert.True(changeShort > changeLong,
"Shorter smoothLength should respond faster to changes");
}
/// <summary>
/// Validates different parameter combinations produce different results.
/// </summary>
[Fact]
public void Cvi_DifferentParameters_ProduceDifferentResults()
{
var bars = GenerateTestData(50);
var cvi1 = new Cvi(10, 10);
var cvi2 = new Cvi(14, 10);
var cvi3 = new Cvi(10, 14);
for (int i = 0; i < bars.Count; i++)
{
cvi1.Update(bars[i]);
cvi2.Update(bars[i]);
cvi3.Update(bars[i]);
}
// Different parameters should produce different values
Assert.NotEqual(cvi1.Last.Value, cvi2.Last.Value);
Assert.NotEqual(cvi1.Last.Value, cvi3.Last.Value);
}
// === Edge Cases ===
/// <summary>
/// Validates handling of very small ranges.
/// </summary>
[Fact]
public void Cvi_VerySmallRanges_HandledCorrectly()
{
var cvi = new Cvi(5, 5);
for (int i = 0; i < 20; i++)
{
// Very small range (0.0001)
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 100.00005, 99.99995, 100.0, 1000.0
);
cvi.Update(bar);
}
Assert.True(double.IsFinite(cvi.Last.Value));
}
/// <summary>
/// Validates handling of very large ranges.
/// </summary>
[Fact]
public void Cvi_VeryLargeRanges_HandledCorrectly()
{
var cvi = new Cvi(5, 5);
for (int i = 0; i < 20; i++)
{
// Large range
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 200.0, 50.0, 150.0, 1000.0
);
cvi.Update(bar);
}
Assert.True(double.IsFinite(cvi.Last.Value));
}
/// <summary>
/// Validates handling of alternating large/small ranges.
/// </summary>
[Fact]
public void Cvi_AlternatingRanges_HandledCorrectly()
{
var cvi = new Cvi(5, 5);
for (int i = 0; i < 20; i++)
{
double range = (i % 2 == 0) ? 5.0 : 20.0;
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 100.0 + range / 2, 100.0 - range / 2, 100.0, 1000.0
);
cvi.Update(bar);
}
Assert.True(double.IsFinite(cvi.Last.Value));
}
/// <summary>
/// Validates warmup period calculation.
/// </summary>
[Theory]
[InlineData(10, 10, 20)]
[InlineData(14, 10, 24)]
[InlineData(5, 20, 25)]
public void Cvi_WarmupPeriod_IsCorrect(int rocLength, int smoothLength, int expectedWarmup)
{
var cvi = new Cvi(rocLength, smoothLength);
Assert.Equal(expectedWarmup, cvi.WarmupPeriod);
}
/// <summary>
/// Validates output range is reasonable for typical market data.
/// </summary>
[Fact]
public void Cvi_OutputRange_IsReasonable()
{
var bars = GenerateTestData(100);
var cvi = new Cvi(10, 10);
for (int i = 0; i < bars.Count; i++)
{
cvi.Update(bars[i]);
}
// CVI is a percentage ROC, typically between -100% and +100% for normal markets
// Extreme values possible but rare
Assert.True(cvi.Last.Value > -500, "CVI should be > -500%");
Assert.True(cvi.Last.Value < 500, "CVI should be < +500%");
}
/// <summary>
/// Validates CVI sign indicates volatility direction.
/// </summary>
[Fact]
public void Cvi_Sign_IndicatesVolatilityDirection()
{
// Test expanding volatility
var cviExpanding = new Cvi(5, 5);
for (int i = 0; i < 15; i++)
{
double range = 5 + i; // Expanding
cviExpanding.Update(new TValue(DateTime.UtcNow.AddMinutes(i), range));
}
// Test contracting volatility
var cviContracting = new Cvi(5, 5);
for (int i = 0; i < 15; i++)
{
double range = 20 - i; // Contracting
if (range < 1)
{
range = 1;
}
cviContracting.Update(new TValue(DateTime.UtcNow.AddMinutes(i), range));
}
Assert.True(cviExpanding.Last.Value > 0, "Expanding volatility should produce positive CVI");
Assert.True(cviContracting.Last.Value < 0, "Contracting volatility should produce negative CVI");
}
/// <summary>
/// Validates bar correction works correctly.
/// </summary>
[Fact]
public void Cvi_BarCorrection_WorksCorrectly()
{
var cvi = new Cvi(5, 5);
var bars = GenerateTestData(20);
// Feed initial bars
for (int i = 0; i < 15; i++)
{
cvi.Update(bars[i], isNew: true);
}
// Add new bar
cvi.Update(bars[15], isNew: true);
double afterNew = cvi.Last.Value;
// Correct with different range
var correctedBar = new TBar(
bars[15].Time,
100, 200, 50, 150, 1000 // Very different range
);
cvi.Update(correctedBar, isNew: false);
double afterCorrection = cvi.Last.Value;
// Restore original
cvi.Update(bars[15], isNew: false);
double afterRestore = cvi.Last.Value;
Assert.NotEqual(afterNew, afterCorrection);
Assert.Equal(afterNew, afterRestore, 10);
}
// === Helper Methods ===
private static double Variance(List<double> values)
{
if (values.Count == 0)
{
return 0;
}
double mean = values.Average();
return values.Average(v => Math.Pow(v - mean, 2));
}
}
+384
View File
@@ -0,0 +1,384 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// CVI: Chaikin's Volatility
/// </summary>
/// <remarks>
/// Chaikin's Volatility measures the rate of change of the EMA-smoothed high-low range.
/// It identifies periods of increasing or decreasing trading range volatility by comparing
/// the current smoothed range to a prior value.
///
/// Formula:
/// <c>Range_t = High_t - Low_t</c>
/// <c>EMA_t = EMA(Range, smoothLength)</c>
/// <c>CVI = ((EMA_t - EMA_{t-rocLength}) / EMA_{t-rocLength}) × 100</c>
///
/// Key properties:
/// - Positive values indicate expanding volatility
/// - Negative values indicate contracting volatility
/// - Uses High-Low range (requires OHLC data)
/// - EMA smoothing reduces noise before ROC calculation
/// </remarks>
[SkipLocalsInit]
public sealed class Cvi : AbstractBase
{
private readonly int _rocLength;
private readonly int _smoothLength;
private readonly double _alpha;
private readonly RingBuffer _emaBuffer;
private const double Epsilon = 1e-10;
[StructLayout(LayoutKind.Auto)]
private record struct State(
double Ema,
double LastValidRange,
int Count);
private State _s;
private State _ps;
/// <summary>
/// Creates CVI with specified parameters.
/// </summary>
/// <param name="rocLength">Period for Rate of Change calculation (must be > 0)</param>
/// <param name="smoothLength">Period for EMA smoothing of high-low range (must be > 0)</param>
/// <exception cref="ArgumentException">Thrown when parameters are invalid</exception>
public Cvi(int rocLength = 10, int smoothLength = 10)
{
if (rocLength <= 0)
{
throw new ArgumentException("ROC length must be greater than 0", nameof(rocLength));
}
if (smoothLength <= 0)
{
throw new ArgumentException("Smooth length must be greater than 0", nameof(smoothLength));
}
_rocLength = rocLength;
_smoothLength = smoothLength;
_alpha = 2.0 / (smoothLength + 1);
_emaBuffer = new RingBuffer(rocLength + 1);
Name = $"Cvi({rocLength},{smoothLength})";
WarmupPeriod = smoothLength + rocLength;
_s = new State(0.0, 0.0, 0);
_ps = _s;
}
/// <summary>
/// Creates CVI with specified source and parameters.
/// </summary>
public Cvi(ITValuePublisher source, int rocLength = 10, int smoothLength = 10) : this(rocLength, smoothLength)
{
source.Pub += Handle;
}
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
/// <summary>
/// True if the indicator has enough data for valid results.
/// </summary>
public override bool IsHot => _s.Count >= WarmupPeriod;
/// <summary>
/// ROC length for the indicator.
/// </summary>
public int RocLength => _rocLength;
/// <summary>
/// Smoothing length for EMA.
/// </summary>
public int SmoothLength => _smoothLength;
/// <summary>
/// Updates CVI with a TValue input (treats value as pre-calculated range).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
return UpdateWithRange(input.Time, input.Value, isNew);
}
/// <summary>
/// Updates CVI with a TBar input (preferred - uses High-Low range).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar input, bool isNew = true)
{
double range = input.High - input.Low;
return UpdateWithRange(input.Time, range, isNew);
}
/// <summary>
/// Updates CVI with a TBarSeries.
/// </summary>
public TSeries Update(TBarSeries source)
{
if (source.Count == 0)
{
return [];
}
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
// Extract high-low ranges
Span<double> ranges = len <= 256 ? stackalloc double[len] : new double[len];
for (int i = 0; i < len; i++)
{
ranges[i] = source[i].High - source[i].Low;
}
Batch(ranges, vSpan, _rocLength, _smoothLength);
for (int i = 0; i < len; i++)
{
tSpan[i] = source[i].Time;
}
// Update internal state
for (int i = 0; i < len; i++)
{
Update(source[i], isNew: true);
}
return new TSeries(t, v);
}
/// <inheritdoc/>
public override TSeries Update(TSeries source)
{
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
Batch(source.Values, vSpan, _rocLength, _smoothLength);
source.Times.CopyTo(tSpan);
// Update internal state
for (int i = 0; i < len; i++)
{
Update(new TValue(source.Times[i], source.Values[i]), isNew: true);
}
return new TSeries(t, v);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private TValue UpdateWithRange(long timeTicks, double range, bool isNew)
{
if (isNew)
{
_ps = _s;
_emaBuffer.Snapshot();
}
else
{
_s = _ps;
_emaBuffer.Restore();
}
var s = _s;
// Sanitize input
if (!double.IsFinite(range) || range < 0)
{
range = double.IsFinite(s.LastValidRange) && s.LastValidRange >= 0 ? s.LastValidRange : 0.0;
}
else
{
s.LastValidRange = range;
}
// Calculate EMA of range
double ema;
if (s.Count == 0)
{
ema = range;
}
else
{
// EMA: ema = (range - prevEma) * alpha + prevEma
ema = Math.FusedMultiplyAdd(range - s.Ema, _alpha, s.Ema);
}
// Always use Add() after Snapshot/Restore pattern
// When isNew=false, Restore() reverts buffer to pre-Add state,
// so we need Add() (not UpdateNewest) to put the value back
_emaBuffer.Add(ema);
if (isNew)
{
s.Ema = ema;
s.Count++;
}
else
{
s.Ema = ema;
}
_s = s;
// Calculate ROC
double result = 0.0;
if (_emaBuffer.Count > _rocLength)
{
// Get EMA value from rocLength bars ago
double oldEma = _emaBuffer[_emaBuffer.Count - 1 - _rocLength];
if (Math.Abs(oldEma) > Epsilon)
{
result = ((ema - oldEma) / oldEma) * 100.0;
}
}
if (!double.IsFinite(result))
{
result = 0.0;
}
Last = new TValue(timeTicks, result);
PubEvent(Last, isNew);
return Last;
}
/// <inheritdoc/>
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
for (int i = 0; i < source.Length; i++)
{
Update(new TValue(DateTime.UtcNow, source[i]), isNew: true);
}
}
/// <inheritdoc/>
public override void Reset()
{
_emaBuffer.Clear();
_s = new State(0.0, 0.0, 0);
_ps = _s;
Last = default;
}
/// <summary>
/// Calculates CVI for entire TBarSeries.
/// </summary>
public static TSeries Calculate(TBarSeries source, int rocLength = 10, int smoothLength = 10)
{
var cvi = new Cvi(rocLength, smoothLength);
return cvi.Update(source);
}
/// <summary>
/// Calculates CVI for entire series (assumes values are pre-calculated ranges).
/// </summary>
public static TSeries Calculate(TSeries source, int rocLength = 10, int smoothLength = 10)
{
if (rocLength <= 0)
{
throw new ArgumentException("ROC length must be greater than 0", nameof(rocLength));
}
if (smoothLength <= 0)
{
throw new ArgumentException("Smooth length must be greater than 0", nameof(smoothLength));
}
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, rocLength, smoothLength);
source.Times.CopyTo(tSpan);
return new TSeries(t, v);
}
/// <summary>
/// Batch CVI calculation.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int rocLength = 10, int smoothLength = 10)
{
if (source.Length != output.Length)
{
throw new ArgumentException("Source and output must have the same length", nameof(output));
}
if (rocLength <= 0)
{
throw new ArgumentException("ROC length must be greater than 0", nameof(rocLength));
}
if (smoothLength <= 0)
{
throw new ArgumentException("Smooth length must be greater than 0", nameof(smoothLength));
}
int len = source.Length;
if (len == 0)
{
return;
}
double alpha = 2.0 / (smoothLength + 1);
var emaBuffer = new RingBuffer(rocLength + 1);
double ema = 0.0;
double lastValidRange = 0.0;
for (int i = 0; i < len; i++)
{
double range = source[i];
// Sanitize input
if (!double.IsFinite(range) || range < 0)
{
range = lastValidRange;
}
else
{
lastValidRange = range;
}
// Calculate EMA
if (i == 0)
{
ema = range;
}
else
{
ema = Math.FusedMultiplyAdd(range - ema, alpha, ema);
}
emaBuffer.Add(ema);
// Calculate ROC
double result = 0.0;
if (emaBuffer.Count > rocLength)
{
double oldEma = emaBuffer[emaBuffer.Count - 1 - rocLength];
if (Math.Abs(oldEma) > Epsilon)
{
result = ((ema - oldEma) / oldEma) * 100.0;
}
}
output[i] = double.IsFinite(result) ? result : 0.0;
}
}
}
+231
View File
@@ -0,0 +1,231 @@
# CVI: Chaikin's Volatility
> "Volatility expansion precedes major moves—when the trading range starts widening, pay attention."
Chaikin's Volatility (CVI) measures the rate of change of the EMA-smoothed high-low trading range. Unlike traditional volatility measures that focus on returns, CVI directly tracks the expansion and contraction of price ranges over time. A positive CVI indicates expanding volatility (wider trading ranges), while a negative CVI signals contracting volatility (narrower ranges). This makes CVI particularly useful for identifying breakout conditions and market transitions.
## Historical Context
Marc Chaikin developed this indicator as part of his suite of technical analysis tools focused on price and volume dynamics. The indicator emerged from a practical observation: before significant price moves, the trading range often expands as buyers and sellers contest prices more aggressively.
Traditional volatility measures like standard deviation or ATR tell you the *level* of volatility, but CVI answers a different question: is volatility *increasing* or *decreasing*? This directional information can be more actionable for traders timing entries and exits.
The indicator combines two smoothing mechanisms: EMA smoothing on the raw high-low range to reduce noise, followed by a Rate of Change (ROC) calculation to measure the trend in volatility. This two-stage approach filters out day-to-day noise while capturing meaningful shifts in market character.
## Architecture & Physics
### 1. Range Calculation
The daily trading range is the difference between high and low prices:
$$
R_t = H_t - L_t
$$
where:
- $H_t$ = high price at time $t$
- $L_t$ = low price at time $t$
- $R_t$ = range at time $t$
This captures the full extent of intraday price movement.
### 2. EMA Smoothing
The range is smoothed using an Exponential Moving Average:
$$
EMA_t = \alpha \cdot R_t + (1 - \alpha) \cdot EMA_{t-1}
$$
where:
- $\alpha = \frac{2}{smoothLength + 1}$ (smoothing factor)
- Default $smoothLength = 10$ gives $\alpha \approx 0.182$
Equivalently, using FMA optimization:
$$
EMA_t = (R_t - EMA_{t-1}) \cdot \alpha + EMA_{t-1}
$$
### 3. Rate of Change Calculation
CVI is the percentage change of the smoothed range over the ROC period:
$$
CVI_t = \frac{EMA_t - EMA_{t-rocLength}}{EMA_{t-rocLength}} \times 100
$$
where:
- $rocLength$ = lookback period for ROC (default 10)
- Output is expressed as a percentage
### 4. Interpretation
$$
CVI_t = \begin{cases}
> 0 & \text{Expanding volatility (range increasing)} \\
= 0 & \text{Stable volatility (range unchanged)} \\
< 0 & \text{Contracting volatility (range decreasing)}
\end{cases}
$$
## Mathematical Foundation
### EMA Properties
**Smoothing Factor:**
$$
\alpha = \frac{2}{n + 1}
$$
| smoothLength | α | Half-life (bars) |
| :---: | :---: | :---: |
| 5 | 0.333 | 1.7 |
| 10 | 0.182 | 3.4 |
| 14 | 0.133 | 4.8 |
| 20 | 0.095 | 6.9 |
**Exponential Decay:**
The weight of a value $k$ bars ago is:
$$
w_k = \alpha (1 - \alpha)^k
$$
### ROC Properties
**Percentage Change Formula:**
$$
ROC = \frac{V_{current} - V_{prior}}{V_{prior}} \times 100
$$
**Symmetry Note:**
A +50% increase followed by -33% decrease returns to the original value. CVI preserves this percentage-based interpretation.
### Combined Effect
The warmup period is the sum of both smoothing requirements:
$$
WarmupPeriod = smoothLength + rocLength
$$
This ensures both the EMA has stabilized and enough history exists for the ROC calculation.
## Performance Profile
### Operation Count (Streaming Mode, Scalar)
Per-bar operations after warmup:
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| SUB (range) | 1 | 1 | 1 |
| FMA (EMA) | 1 | 4 | 4 |
| Buffer lookup | 1 | 3 | 3 |
| SUB | 1 | 1 | 1 |
| DIV | 1 | 15 | 15 |
| MUL (×100) | 1 | 3 | 3 |
| **Total** | — | — | **~27 cycles** |
The primary cost is the division for the ROC calculation.
### Batch Mode (512 values, SIMD/FMA)
| Operation | Scalar Ops | SIMD Ops (AVX2) | Speedup |
| :--- | :---: | :---: | :---: |
| Range calculation | 512 | 64 | 8× |
| EMA (sequential) | 512 | 512 | 1× |
| ROC calculation | 512 | 64 | 8× |
**Note:** EMA is inherently sequential due to the $EMA_{t-1}$ dependency. Total batch improvement is limited by this constraint.
### Memory Profile
- **Per instance:** ~80 bytes (state struct + RingBuffer header)
- **RingBuffer:** $(rocLength + 1) \times 8$ bytes for EMA history
- **Default (10,10):** ~80 + 88 = ~168 bytes per instance
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 8/10 | Direct measure of range dynamics |
| **Timeliness** | 7/10 | EMA introduces lag |
| **Smoothness** | 8/10 | Two-stage smoothing reduces noise |
| **Interpretability** | 9/10 | Clear meaning: + expanding, - contracting |
| **Robustness** | 8/10 | Handles gaps and spikes well |
## Validation
CVI is a classic indicator with multiple implementations:
| Library | Status | Notes |
| :--- | :---: | :--- |
| **TA-Lib** | N/A | Not implemented |
| **Skender** | N/A | Not implemented |
| **Tulip** | N/A | Not implemented |
| **OoplesFinance** | N/A | Not implemented |
| **PineScript** | ✅ | Matches cvi.pine reference |
| **Manual** | ✅ | Validated against formula |
Note: While many libraries include ATR or standard deviation-based volatility, Chaikin's specific ROC-of-EMA-range formulation is less common.
## Common Pitfalls
1. **Warmup period**: CVI requires $smoothLength + rocLength$ bars before producing meaningful results. With defaults (10,10), this means 20 bars. The `IsHot` property indicates when warmup is complete.
2. **Zero/near-zero old EMA**: If the historical EMA value is very small (near zero), the division can produce extreme or infinite values. The implementation guards against this with an epsilon threshold.
3. **Interpretation of magnitude**: CVI values are percentages, not absolute ranges. A CVI of +50 means volatility increased 50% compared to $rocLength$ bars ago, regardless of the actual range values.
4. **Not a directional indicator**: CVI measures volatility direction, not price direction. High CVI can precede moves in either direction.
5. **Parameter sensitivity**:
- Shorter $smoothLength$ = more responsive to range changes but noisier
- Shorter $rocLength$ = more volatile CVI readings
- Common combinations: (10,10), (14,10), (10,14)
6. **Requires OHLC data**: Unlike many indicators that work with closing prices only, CVI requires high and low prices. When using TValue input, the value is interpreted as a pre-calculated range.
7. **Negative ranges**: If TValue input has negative values (invalid for a range), the implementation substitutes the last valid value.
## Trading Applications
### Breakout Detection
High positive CVI values suggest expanding volatility, often preceding breakouts:
```
Entry signal: CVI crosses above +20 (volatility expanding)
Confirmation: Price breaks key support/resistance
```
### Consolidation Identification
Sustained negative CVI indicates contracting ranges, typical of consolidation:
```
Consolidation: CVI < -10 for several bars
Watch for: CVI reversal signaling potential breakout
```
### Volatility Regime Filter
CVI can filter other signals based on volatility conditions:
```
Trade breakouts when: CVI > 0 (expanding volatility)
Avoid range trades when: CVI rising sharply
```
## References
- Chaikin, M. (1966). "Stock Market Trading Systems." Various publications and interviews.
- Achelis, S. B. (2000). "Technical Analysis from A to Z." McGraw-Hill. Chapter on Chaikin Volatility.
- Murphy, J. J. (1999). "Technical Analysis of the Financial Markets." New York Institute of Finance.