more volatilty

This commit is contained in:
Miha Kralj
2026-02-02 13:42:47 -08:00
parent dde19f2226
commit a03d7aa0ce
89 changed files with 21551 additions and 438 deletions
+326
View File
@@ -0,0 +1,326 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public class RsvIndicatorTests
{
[Fact]
public void RsvIndicator_Constructor_SetsDefaults()
{
var indicator = new RsvIndicator();
Assert.Equal(20, indicator.Period);
Assert.True(indicator.Annualize);
Assert.Equal(252, indicator.AnnualPeriods);
Assert.True(indicator.ShowColdValues);
Assert.Equal("RSV - Rogers-Satchell Volatility", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void RsvIndicator_ShortName_IncludesParameters()
{
var indicator = new RsvIndicator { Period = 14 };
Assert.Contains("RSV", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("14", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void RsvIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new RsvIndicator();
Assert.Equal(0, RsvIndicator.MinHistoryDepths);
Assert.Equal(0, ((IWatchlistIndicator)indicator).MinHistoryDepths);
}
[Fact]
public void RsvIndicator_Initialize_CreatesInternalRsv()
{
var indicator = new RsvIndicator();
// Initialize should not throw
indicator.Initialize();
// After init, line series should exist
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void RsvIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new RsvIndicator { Period = 10 };
indicator.Initialize();
// Add historical data with varying volatility
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
double basePrice = 100 + i;
double range = 2 + (i % 5); // Varying ranges
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + range, basePrice - range, basePrice + 1, 1000);
// Process update for each bar to simulate history loading
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
// Line series should have a value
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val));
Assert.True(val >= 0, "Volatility should be non-negative");
}
[Fact]
public void RsvIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new RsvIndicator { Period = 10 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
double basePrice = 100 + i;
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 5, basePrice - 5, basePrice + 2, 1000);
}
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Add new bar with larger range
indicator.HistoricalData.AddBar(now.AddMinutes(30), 120, 135, 105, 125, 1500);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void RsvIndicator_DifferentPeriods_Work()
{
int[] periods = { 5, 10, 14, 20 };
foreach (var period in periods)
{
var indicator = new RsvIndicator { Period = period };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 50; i++)
{
double basePrice = 100 + i;
double range = 3 + (i % 4);
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + range, basePrice - range, basePrice + 1, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val), $"Period {period} should produce finite value");
Assert.True(val >= 0, $"Period {period} should produce non-negative value");
}
}
[Fact]
public void RsvIndicator_Period_CanBeChanged()
{
var indicator = new RsvIndicator();
Assert.Equal(20, indicator.Period);
indicator.Period = 14;
Assert.Equal(14, indicator.Period);
indicator.Period = 10;
Assert.Equal(10, indicator.Period);
}
[Fact]
public void RsvIndicator_Annualize_CanBeToggled()
{
var indicator = new RsvIndicator();
Assert.True(indicator.Annualize);
indicator.Annualize = false;
Assert.False(indicator.Annualize);
indicator.Annualize = true;
Assert.True(indicator.Annualize);
}
[Fact]
public void RsvIndicator_AnnualPeriods_CanBeChanged()
{
var indicator = new RsvIndicator();
Assert.Equal(252, indicator.AnnualPeriods);
indicator.AnnualPeriods = 365;
Assert.Equal(365, indicator.AnnualPeriods);
indicator.AnnualPeriods = 52;
Assert.Equal(52, indicator.AnnualPeriods);
}
[Fact]
public void RsvIndicator_ShowColdValues_CanBeToggled()
{
var indicator = new RsvIndicator();
Assert.True(indicator.ShowColdValues);
indicator.ShowColdValues = false;
Assert.False(indicator.ShowColdValues);
indicator.ShowColdValues = true;
Assert.True(indicator.ShowColdValues);
}
[Fact]
public void RsvIndicator_SourceCodeLink_IsValid()
{
var indicator = new RsvIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Rsv.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void RsvIndicator_HighVolatility_ProducesHigherValue()
{
var indicator1 = new RsvIndicator { Period = 10, Annualize = false };
var indicator2 = new RsvIndicator { Period = 10, Annualize = false };
indicator1.Initialize();
indicator2.Initialize();
var now = DateTime.UtcNow;
// Indicator 1: low volatility (narrow range)
for (int i = 0; i < 30; i++)
{
double basePrice = 100;
indicator1.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 1, basePrice - 1, basePrice + 0.5, 1000);
indicator1.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
// Indicator 2: high volatility (wide range)
for (int i = 0; i < 30; i++)
{
double basePrice = 100;
indicator2.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 10, basePrice - 10, basePrice + 2, 1000);
indicator2.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double lowVol = indicator1.LinesSeries[0].GetValue(0);
double highVol = indicator2.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(lowVol));
Assert.True(double.IsFinite(highVol));
Assert.True(highVol > lowVol, "Higher volatility bars should produce higher RSV value");
}
[Fact]
public void RsvIndicator_AnnualizedValue_IsScaled()
{
var indicatorRaw = new RsvIndicator { Period = 10, Annualize = false };
var indicatorAnn = new RsvIndicator { Period = 10, Annualize = true, AnnualPeriods = 252 };
indicatorRaw.Initialize();
indicatorAnn.Initialize();
var now = DateTime.UtcNow;
// Same data for both
for (int i = 0; i < 30; i++)
{
double basePrice = 100 + i * 0.5;
indicatorRaw.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 3, basePrice - 3, basePrice + 1, 1000);
indicatorRaw.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicatorAnn.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 3, basePrice - 3, basePrice + 1, 1000);
indicatorAnn.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double rawValue = indicatorRaw.LinesSeries[0].GetValue(0);
double annValue = indicatorAnn.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(rawValue));
Assert.True(double.IsFinite(annValue));
// Annualized should be approximately sqrt(252) times larger
double expectedRatio = Math.Sqrt(252);
double actualRatio = annValue / rawValue;
Assert.True(Math.Abs(actualRatio - expectedRatio) < 0.01,
$"Annualized value should be ~{expectedRatio:F2}× raw, got {actualRatio:F2}×");
}
[Fact]
public void RsvIndicator_UsesAllOhlc_SensitiveToOpenClose()
{
// Test that RSV uses all OHLC prices (unlike HLV which only uses H-L)
var indicator1 = new RsvIndicator { Period = 10, Annualize = false };
var indicator2 = new RsvIndicator { Period = 10, Annualize = false };
indicator1.Initialize();
indicator2.Initialize();
var now = DateTime.UtcNow;
// Same high/low range but different open/close
for (int i = 0; i < 30; i++)
{
// Indicator 1: open = close (doji pattern at center)
indicator1.HistoricalData.AddBar(now.AddMinutes(i), 100, 105, 95, 100, 1000);
indicator1.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
// Indicator 2: open and close at extremes (strong directional move)
indicator2.HistoricalData.AddBar(now.AddMinutes(i), 95.5, 105, 95, 104.5, 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));
// RSV should be different since it uses all OHLC prices
Assert.NotEqual(val1, val2, 5); // Values should differ significantly
}
[Fact]
public void RsvIndicator_ConstantPrice_ProducesZeroVolatility()
{
var indicator = new RsvIndicator { Period = 10, Annualize = false };
indicator.Initialize();
var now = DateTime.UtcNow;
// Constant price (no volatility) - but need small spread to avoid log(1) issues
for (int i = 0; i < 30; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100, 100.01, 99.99, 100, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val));
Assert.True(val < 0.01, "Near-constant price should produce near-zero volatility");
}
[Fact]
public void RsvIndicator_DriftAdjusted_HandlesUptrend()
{
// RSV is drift-adjusted, so should handle trending markets well
var indicator = new RsvIndicator { Period = 10, Annualize = false };
indicator.Initialize();
var now = DateTime.UtcNow;
// Strong uptrend with consistent volatility
for (int i = 0; i < 30; i++)
{
double basePrice = 100 + i * 2; // Trending up
indicator.HistoricalData.AddBar(now.AddMinutes(i), basePrice, basePrice + 3, basePrice - 2, basePrice + 1, 1000);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
double val = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(val));
Assert.True(val > 0, "Trending market with volatility should produce positive RSV");
}
}
+55
View File
@@ -0,0 +1,55 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class RsvIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 1, 1, 1000, 1, 0)]
public int Period { get; set; } = 20;
[InputParameter("Annualize", sortIndex: 2)]
public bool Annualize { get; set; } = true;
[InputParameter("Annual Periods", sortIndex: 3, 1, 365, 1, 0)]
public int AnnualPeriods { get; set; } = 252;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Rsv _rsv = null!;
private readonly LineSeries _series;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"RSV {Period}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/volatility/rsv/Rsv.Quantower.cs";
public RsvIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "RSV - Rogers-Satchell Volatility";
Description = "Rogers-Satchell Volatility is a drift-adjusted OHLC-based volatility estimator that uses all four price points (Open, High, Low, Close) to provide more accurate volatility estimates than range-based methods";
_series = new LineSeries(name: "RSV", color: IndicatorExtensions.Volatility, width: 2, style: LineStyle.Solid);
AddLineSeries(_series);
}
protected override void OnInit()
{
_rsv = new Rsv(Period, Annualize, AnnualPeriods);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
TBar bar = this.GetInputBar(args);
TValue result = _rsv.Update(bar, isNew: args.IsNewBar());
_series.SetValue(result.Value, _rsv.IsHot, ShowColdValues);
}
}
+717
View File
@@ -0,0 +1,717 @@
namespace QuanTAlib.Tests;
using Xunit;
public class RsvTests
{
private const double Tolerance = 1e-9;
private static TBarSeries GenerateTestData(int count = 100)
{
var gbm = new GBM(seed: 42);
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
}
#region Constructor Tests
[Fact]
public void Constructor_DefaultParameters_SetsCorrectValues()
{
var rsv = new Rsv();
Assert.Equal(20, rsv.Period);
Assert.True(rsv.Annualize);
Assert.Equal(252, rsv.AnnualPeriods);
Assert.Equal("Rsv(20)", rsv.Name);
Assert.Equal(20, rsv.WarmupPeriod);
}
[Fact]
public void Constructor_CustomParameters_SetsCorrectValues()
{
var rsv = new Rsv(period: 10, annualize: false, annualPeriods: 365);
Assert.Equal(10, rsv.Period);
Assert.False(rsv.Annualize);
Assert.Equal(365, rsv.AnnualPeriods);
Assert.Equal("Rsv(10)", rsv.Name);
}
[Fact]
public void Constructor_ZeroPeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Rsv(period: 0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_NegativePeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Rsv(period: -1));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_ZeroAnnualPeriodsWhenAnnualizing_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Rsv(period: 10, annualize: true, annualPeriods: 0));
Assert.Equal("annualPeriods", ex.ParamName);
}
[Fact]
public void Constructor_ZeroAnnualPeriodsWhenNotAnnualizing_DoesNotThrow()
{
var rsv = new Rsv(period: 10, annualize: false, annualPeriods: 0);
Assert.Equal(0, rsv.AnnualPeriods);
}
#endregion
#region Basic Calculation Tests
[Fact]
public void Update_SingleBar_ReturnsNonNegativeValue()
{
var rsv = new Rsv(period: 5);
var bar = new TBar(DateTime.UtcNow, 100.0, 105.0, 98.0, 102.0, 1000);
var result = rsv.Update(bar);
Assert.True(result.Value >= 0, "RSV should return non-negative values");
}
[Fact]
public void Update_MultipleBars_ReturnsCorrectCount()
{
var rsv = new Rsv(period: 5);
var bars = GenerateTestData(10);
for (int i = 0; i < bars.Count; i++)
{
rsv.Update(bars[i]);
}
Assert.True(rsv.IsHot, "Indicator should be hot after warmup period");
}
[Fact]
public void Update_ReturnsLastValue()
{
var rsv = new Rsv(period: 5);
var bar = new TBar(DateTime.UtcNow, 100.0, 105.0, 98.0, 102.0, 1000);
var result = rsv.Update(bar);
Assert.Equal(result.Value, rsv.Last.Value, Tolerance);
}
[Fact]
public void Update_WithoutAnnualization_ReturnsSmallerValues()
{
var rsvAnnual = new Rsv(period: 10, annualize: true, annualPeriods: 252);
var rsvNoAnnual = new Rsv(period: 10, annualize: false);
var bars = GenerateTestData(20);
double lastAnnual = 0;
double lastNoAnnual = 0;
for (int i = 0; i < bars.Count; i++)
{
lastAnnual = rsvAnnual.Update(bars[i]).Value;
lastNoAnnual = rsvNoAnnual.Update(bars[i]).Value;
}
// Annualized values should be larger by factor of sqrt(252)
Assert.True(lastAnnual > lastNoAnnual, "Annualized values should be larger");
}
#endregion
#region State Management Tests
[Fact]
public void Update_IsNewTrue_AdvancesState()
{
var rsv = new Rsv(period: 5);
var bar1 = new TBar(DateTime.UtcNow, 100.0, 105.0, 98.0, 102.0, 1000);
var bar2 = new TBar(DateTime.UtcNow.AddMinutes(1), 102.0, 107.0, 100.0, 105.0, 1000);
rsv.Update(bar1, isNew: true);
var result1 = rsv.Last.Value;
rsv.Update(bar2, isNew: true);
var result2 = rsv.Last.Value;
Assert.NotEqual(result1, result2);
}
[Fact]
public void Update_IsNewFalse_UpdatesCurrentBar()
{
var rsv = new Rsv(period: 5);
var bar1 = new TBar(DateTime.UtcNow, 100.0, 105.0, 98.0, 102.0, 1000);
rsv.Update(bar1, isNew: true);
var firstValue = rsv.Last.Value;
// Update the same bar with different OHLC values
var bar1Updated = new TBar(DateTime.UtcNow, 99.0, 110.0, 95.0, 108.0, 1000);
rsv.Update(bar1Updated, isNew: false);
var updatedValue = rsv.Last.Value;
Assert.NotEqual(firstValue, updatedValue);
}
[Fact]
public void Update_IterativeCorrections_RestoresState()
{
var rsv = new Rsv(period: 5);
var bars = GenerateTestData(10);
// Process first 5 bars
for (int i = 0; i < 5; i++)
{
rsv.Update(bars[i], isNew: true);
}
// Add bar 6 and correct multiple times
rsv.Update(bars[5], isNew: true);
rsv.Update(bars[5], isNew: false);
rsv.Update(bars[5], isNew: false);
rsv.Update(bars[5], isNew: false);
// Now continue with bar 7
rsv.Update(bars[6], isNew: true);
// Create new instance and process same data
var rsv2 = new Rsv(period: 5);
for (int i = 0; i < 7; i++)
{
rsv2.Update(bars[i], isNew: true);
}
Assert.Equal(rsv.Last.Value, rsv2.Last.Value, Tolerance);
}
#endregion
#region IsHot and Warmup Tests
[Fact]
public void IsHot_BeforeWarmup_ReturnsFalse()
{
var rsv = new Rsv(period: 10);
var bars = GenerateTestData(5);
for (int i = 0; i < bars.Count; i++)
{
rsv.Update(bars[i]);
}
Assert.False(rsv.IsHot);
}
[Fact]
public void IsHot_AfterWarmup_ReturnsTrue()
{
var rsv = new Rsv(period: 10);
var bars = GenerateTestData(15);
for (int i = 0; i < bars.Count; i++)
{
rsv.Update(bars[i]);
}
Assert.True(rsv.IsHot);
}
[Fact]
public void IsHot_ExactlyAtWarmup_ReturnsTrue()
{
var rsv = new Rsv(period: 10);
var bars = GenerateTestData(10);
for (int i = 0; i < bars.Count; i++)
{
rsv.Update(bars[i]);
}
Assert.True(rsv.IsHot);
}
#endregion
#region Reset Tests
[Fact]
public void Reset_ClearsState()
{
var rsv = new Rsv(period: 5);
var bars = GenerateTestData(10);
for (int i = 0; i < bars.Count; i++)
{
rsv.Update(bars[i]);
}
rsv.Reset();
Assert.False(rsv.IsHot);
Assert.Equal(0, rsv.Last.Value);
}
[Fact]
public void Reset_AllowsReprocessing()
{
var rsv = new Rsv(period: 5);
var bars = GenerateTestData(10);
// First pass
for (int i = 0; i < bars.Count; i++)
{
rsv.Update(bars[i]);
}
var firstResult = rsv.Last.Value;
// Reset and second pass
rsv.Reset();
for (int i = 0; i < bars.Count; i++)
{
rsv.Update(bars[i]);
}
var secondResult = rsv.Last.Value;
Assert.Equal(firstResult, secondResult, Tolerance);
}
#endregion
#region Robustness Tests
[Fact]
public void Update_WithNaNValues_UsesLastValidVariance()
{
var rsv = new Rsv(period: 5);
var bars = GenerateTestData(10);
for (int i = 0; i < bars.Count; i++)
{
rsv.Update(bars[i]);
}
var valueBeforeInvalid = rsv.Last.Value;
// Bar with NaN high - should use last valid RS variance
var nanBar = new TBar(DateTime.UtcNow, 100.0, double.NaN, 98.0, 102.0, 1000);
var result = rsv.Update(nanBar);
// Result should be finite and close to previous (SMA smoothed)
Assert.True(double.IsFinite(result.Value), "Result should be finite when using last valid variance");
Assert.True(result.Value >= 0, "Volatility should be non-negative");
double relativeDiff = Math.Abs(result.Value - valueBeforeInvalid) / valueBeforeInvalid;
Assert.True(relativeDiff < 0.2, $"Value should be similar to previous: {valueBeforeInvalid} vs {result.Value}");
}
[Fact]
public void Update_WithInfinityValues_UsesLastValidVariance()
{
var rsv = new Rsv(period: 5);
var bars = GenerateTestData(10);
for (int i = 0; i < bars.Count; i++)
{
rsv.Update(bars[i]);
}
var valueBeforeInvalid = rsv.Last.Value;
// Bar with infinity - should use last valid RS variance
var infBar = new TBar(DateTime.UtcNow, 100.0, double.PositiveInfinity, 98.0, 102.0, 1000);
var result = rsv.Update(infBar);
Assert.True(double.IsFinite(result.Value), "Result should be finite when using last valid variance");
Assert.True(result.Value >= 0, "Volatility should be non-negative");
double relativeDiff = Math.Abs(result.Value - valueBeforeInvalid) / valueBeforeInvalid;
Assert.True(relativeDiff < 0.2, $"Value should be similar to previous: {valueBeforeInvalid} vs {result.Value}");
}
[Fact]
public void Update_WithZeroPrices_UsesLastValidVariance()
{
var rsv = new Rsv(period: 5);
var bars = GenerateTestData(10);
for (int i = 0; i < bars.Count; i++)
{
rsv.Update(bars[i]);
}
var valueBeforeInvalid = rsv.Last.Value;
// Bar with zero low (invalid for log) - should use last valid RS variance
var zeroBar = new TBar(DateTime.UtcNow, 100.0, 105.0, 0.0, 102.0, 1000);
var result = rsv.Update(zeroBar);
Assert.True(double.IsFinite(result.Value), "Result should be finite when using last valid variance");
Assert.True(result.Value >= 0, "Volatility should be non-negative");
double relativeDiff = Math.Abs(result.Value - valueBeforeInvalid) / valueBeforeInvalid;
Assert.True(relativeDiff < 0.2, $"Value should be similar to previous: {valueBeforeInvalid} vs {result.Value}");
}
[Fact]
public void Update_WithNegativePrices_UsesLastValidVariance()
{
var rsv = new Rsv(period: 5);
var bars = GenerateTestData(10);
for (int i = 0; i < bars.Count; i++)
{
rsv.Update(bars[i]);
}
var valueBeforeInvalid = rsv.Last.Value;
// Bar with negative price - should use last valid RS variance
var negBar = new TBar(DateTime.UtcNow, 100.0, 105.0, -98.0, 102.0, 1000);
var result = rsv.Update(negBar);
Assert.True(double.IsFinite(result.Value), "Result should be finite when using last valid variance");
Assert.True(result.Value >= 0, "Volatility should be non-negative");
double relativeDiff = Math.Abs(result.Value - valueBeforeInvalid) / valueBeforeInvalid;
Assert.True(relativeDiff < 0.2, $"Value should be similar to previous: {valueBeforeInvalid} vs {result.Value}");
}
#endregion
#region Batch and Series Tests
[Fact]
public void Batch_MatchesStreamingResults()
{
const int dataCount = 100;
var bars = GenerateTestData(dataCount);
// Streaming
var rsvStreaming = new Rsv(period: 10);
var streamingResults = new double[dataCount];
for (int i = 0; i < dataCount; i++)
{
streamingResults[i] = rsvStreaming.Update(bars[i]).Value;
}
// Batch (RSV uses all OHLC)
var opens = new double[dataCount];
var highs = new double[dataCount];
var lows = new double[dataCount];
var closes = new double[dataCount];
var batchResults = new double[dataCount];
for (int i = 0; i < dataCount; i++)
{
opens[i] = bars[i].Open;
highs[i] = bars[i].High;
lows[i] = bars[i].Low;
closes[i] = bars[i].Close;
}
Rsv.Batch(opens, highs, lows, closes, batchResults, period: 10);
// Compare last 50 values (after warmup)
for (int i = 50; i < dataCount; i++)
{
Assert.Equal(streamingResults[i], batchResults[i], Tolerance);
}
}
[Fact]
public void Calculate_TBarSeries_ReturnsCorrectLength()
{
const int dataCount = 50;
var barSeries = GenerateTestData(dataCount);
var result = Rsv.Calculate(barSeries, period: 10);
Assert.Equal(dataCount, result.Count);
}
[Fact]
public void Update_TBarSeries_MatchesStreamingResults()
{
const int dataCount = 50;
var barSeries = GenerateTestData(dataCount);
// Series update
var rsvSeries = new Rsv(period: 10);
var seriesResult = rsvSeries.Update(barSeries);
// Streaming
var rsvStreaming = new Rsv(period: 10);
var streamingResults = new double[dataCount];
for (int i = 0; i < dataCount; i++)
{
streamingResults[i] = rsvStreaming.Update(barSeries[i]).Value;
}
// Compare last 30 values
for (int i = 20; i < dataCount; i++)
{
Assert.Equal(streamingResults[i], seriesResult.Values[i], Tolerance);
}
}
[Fact]
public void Batch_EmptyInput_DoesNotThrow()
{
var opens = Array.Empty<double>();
var highs = Array.Empty<double>();
var lows = Array.Empty<double>();
var closes = Array.Empty<double>();
var output = Array.Empty<double>();
// Should not throw
Rsv.Batch(opens, highs, lows, closes, output, period: 10);
Assert.Empty(output);
}
[Fact]
public void Batch_MismatchedLengths_ThrowsArgumentException()
{
var opens = new double[10];
var highs = new double[10];
var lows = new double[5]; // Mismatched
var closes = new double[10];
var output = new double[10];
var ex = Assert.Throws<ArgumentException>(() =>
Rsv.Batch(opens, highs, lows, closes, output, period: 10));
Assert.Equal("close", ex.ParamName);
}
[Fact]
public void Batch_OutputTooShort_ThrowsArgumentException()
{
var opens = new double[10];
var highs = new double[10];
var lows = new double[10];
var closes = new double[10];
var output = new double[5]; // Too short
var ex = Assert.Throws<ArgumentException>(() =>
Rsv.Batch(opens, highs, lows, closes, output, period: 10));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Batch_InvalidPeriod_ThrowsArgumentException()
{
var opens = new double[10];
var highs = new double[10];
var lows = new double[10];
var closes = new double[10];
var output = new double[10];
var ex = Assert.Throws<ArgumentException>(() =>
Rsv.Batch(opens, highs, lows, closes, output, period: 0));
Assert.Equal("period", ex.ParamName);
}
#endregion
#region Event Publishing Tests
[Fact]
public void Update_PublishesEvent()
{
var rsv = new Rsv(period: 5);
bool eventFired = false;
rsv.Pub += (object? sender, in TValueEventArgs args) => eventFired = true;
var bar = new TBar(DateTime.UtcNow, 100.0, 105.0, 98.0, 102.0, 1000);
rsv.Update(bar);
Assert.True(eventFired);
}
[Fact]
public void ChainedIndicator_ReceivesValues()
{
var source = new Rsv(period: 5);
var downstream = new Sma(source, period: 3);
var bars = GenerateTestData(10);
for (int i = 0; i < bars.Count; i++)
{
source.Update(bars[i]);
}
Assert.True(downstream.Last.Value > 0, "Downstream indicator should receive values");
}
#endregion
#region TValue Update Tests
[Fact]
public void Update_TValue_TreatsAsPrecomputedVariance()
{
var rsv1 = new Rsv(period: 5);
var rsv2 = new Rsv(period: 5);
// For rsv1, use bar data
var bar = new TBar(DateTime.UtcNow, 100.0, 105.0, 98.0, 102.0, 1000);
rsv1.Update(bar);
// For rsv2, use pre-computed RS variance value
// Compute manually following the formula
double o = 100.0, h = 105.0, l = 98.0, c = 102.0;
double term1 = Math.Log(h / o);
double term2 = Math.Log(h / c);
double term3 = Math.Log(l / o);
double term4 = Math.Log(l / c);
double rsVariance = (term1 * term2) + (term3 * term4);
var tvalue = new TValue(bar.Time, rsVariance);
rsv2.Update(tvalue);
Assert.Equal(rsv1.Last.Value, rsv2.Last.Value, Tolerance);
}
#endregion
#region RSV-Specific Tests
[Fact]
public void Rsv_UsesAllOhlcPrices()
{
// RSV uses all OHLC, so changing Open should affect result (unlike HLV)
var rsv1 = new Rsv(period: 5);
var rsv2 = new Rsv(period: 5);
// Bars with same H-L-C but different Open
var bar1 = new TBar(DateTime.UtcNow, 100.0, 105.0, 98.0, 102.0, 1000);
var bar2 = new TBar(DateTime.UtcNow, 99.0, 105.0, 98.0, 102.0, 1000); // Different Open
var result1 = rsv1.Update(bar1).Value;
var result2 = rsv2.Update(bar2).Value;
// Results should be different since Open matters for RSV
Assert.NotEqual(result1, result2);
}
[Fact]
public void Rsv_UsesSmaMakesSmoothTransitions()
{
// SMA should produce smoother transitions than RMA
var rsv = new Rsv(period: 10);
var bars = GenerateTestData(50);
var results = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
results[i] = rsv.Update(bars[i]).Value;
}
// Check that results don't have extreme jumps after warmup
for (int i = 11; i < bars.Count; i++)
{
double change = Math.Abs(results[i] - results[i - 1]);
double avg = (results[i] + results[i - 1]) / 2;
if (avg > 0.001) // Avoid division by very small numbers
{
double relativeChange = change / avg;
Assert.True(relativeChange < 0.5, $"SMA should produce smooth transitions: change={relativeChange:P} at index {i}");
}
}
}
[Fact]
public void Rsv_DriftAdjusted_HandlesTrendingMarket()
{
// RSV is drift-adjusted, so trending markets should still produce reasonable volatility
var rsv = new Rsv(period: 10, annualize: false);
// Create trending bars (each bar higher than previous)
var bars = new TBarSeries();
double basePrice = 100.0;
for (int i = 0; i < 20; i++)
{
double trend = i * 0.5; // Upward trend
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i),
basePrice + trend,
basePrice + trend + 2.0,
basePrice + trend - 1.0,
basePrice + trend + 1.5,
1000
);
bars.Add(bar);
rsv.Update(bar);
}
// RSV should still produce reasonable (non-inflated) volatility despite drift
Assert.True(rsv.Last.Value > 0, "RSV should be positive");
Assert.True(rsv.Last.Value < 1.0, "RSV (non-annualized) should be reasonable despite trending market");
}
[Fact]
public void LargeDataset_Performance()
{
var rsv = new Rsv(period: 20);
var bars = GenerateTestData(5000);
for (int i = 0; i < bars.Count; i++)
{
var result = rsv.Update(bars[i]);
Assert.True(double.IsFinite(result.Value));
}
}
[Fact]
public void DifferentParameters_ProduceDistinctValues()
{
var bars = GenerateTestData(50);
var rsv1 = new Rsv(period: 10);
var rsv2 = new Rsv(period: 20);
var rsv3 = new Rsv(period: 10, annualize: false);
for (int i = 0; i < bars.Count; i++)
{
rsv1.Update(bars[i]);
rsv2.Update(bars[i]);
rsv3.Update(bars[i]);
}
Assert.True(double.IsFinite(rsv1.Last.Value));
Assert.True(double.IsFinite(rsv2.Last.Value));
Assert.True(double.IsFinite(rsv3.Last.Value));
// Different parameters should produce different values
Assert.NotEqual(rsv1.Last.Value, rsv2.Last.Value);
Assert.NotEqual(rsv1.Last.Value, rsv3.Last.Value);
}
[Fact]
public void StaticCalculate_Works()
{
var bars = GenerateTestData(100);
var result = Rsv.Calculate(bars, period: 14);
Assert.Equal(100, result.Count);
Assert.True(double.IsFinite(result[result.Count - 1].Value));
}
[Fact]
public void StaticCalculate_ValidatesInput()
{
var bars = GenerateTestData(10);
Assert.Throws<ArgumentException>(() => Rsv.Calculate(bars, period: 0));
Assert.Throws<ArgumentException>(() => Rsv.Calculate(bars, period: -1));
Assert.Throws<ArgumentException>(() => Rsv.Calculate(bars, period: 10, annualize: true, annualPeriods: 0));
}
[Fact]
public void Prime_Works()
{
var rsv = new Rsv(period: 5);
var values = new double[] { 0.001, 0.002, 0.0015, 0.0018, 0.0012, 0.0022 };
rsv.Prime(values);
Assert.True(rsv.IsHot);
Assert.True(double.IsFinite(rsv.Last.Value));
}
#endregion
}
+713
View File
@@ -0,0 +1,713 @@
namespace QuanTAlib.Test;
using Xunit;
/// <summary>
/// Validation tests for RSV (Rogers-Satchell Volatility).
/// RSV is an OHLC-based volatility estimator with drift adjustment.
/// Formula: rs_variance = log(H/O)*log(H/C) + log(L/O)*log(L/C)
/// SMA smoothing applied (not RMA like HLV).
/// </summary>
public class RsvValidationTests
{
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 Rogers-Satchell variance formula:
/// rs_variance = log(H/O)*log(H/C) + log(L/O)*log(L/C)
/// </summary>
[Fact]
public void Rsv_RsVarianceFormula_IsCorrect()
{
double open = 100.0;
double high = 105.0;
double low = 95.0;
double close = 102.0;
double lnHO = Math.Log(high / open); // log(105/100) ≈ 0.04879
double lnHC = Math.Log(high / close); // log(105/102) ≈ 0.02899
double lnLO = Math.Log(low / open); // log(95/100) ≈ -0.05129
double lnLC = Math.Log(low / close); // log(95/102) ≈ -0.07115
double term1 = lnHO * lnHC; // positive * positive = positive
double term2 = lnLO * lnLC; // negative * negative = positive
double rsVariance = term1 + term2;
Assert.True(rsVariance >= 0, "RS variance should be non-negative for valid OHLC");
}
/// <summary>
/// Validates RS variance is zero for flat bar (O=H=L=C).
/// </summary>
[Fact]
public void Rsv_FlatBar_ProducesZeroVariance()
{
double price = 100.0;
double lnHO = Math.Log(price / price); // log(1) = 0
double lnHC = Math.Log(price / price); // log(1) = 0
double lnLO = Math.Log(price / price); // log(1) = 0
double lnLC = Math.Log(price / price); // log(1) = 0
double rsVariance = lnHO * lnHC + lnLO * lnLC; // 0
Assert.Equal(0.0, rsVariance, 15);
}
/// <summary>
/// Validates SMA smoothing formula (unlike RMA used in HLV).
/// </summary>
[Fact]
public void Rsv_UsesSmaSmoothing_NotRma()
{
// SMA sums values and divides by period
// RMA uses exponential decay
double[] values = { 1, 2, 3, 4, 5 };
int period = 5;
double smaExpected = values.Average();
Assert.Equal(3.0, smaExpected, 10);
// SMA is simple mean, not weighted
double sum = values.Sum();
double smaManual = sum / period;
Assert.Equal(smaExpected, smaManual, 10);
}
/// <summary>
/// Validates annualization factor: √(annualPeriods)
/// </summary>
[Theory]
[InlineData(252, 15.8745078663875)] // Daily trading days
[InlineData(365, 19.1049731745428)] // Calendar days
[InlineData(52, 7.21110255092798)] // Weekly
[InlineData(12, 3.46410161513775)] // Monthly
public void Rsv_AnnualizationFactor_IsCorrect(int annualPeriods, double expectedFactor)
{
double factor = Math.Sqrt(annualPeriods);
Assert.Equal(expectedFactor, factor, 10);
}
/// <summary>
/// Validates that wider range produces higher RS variance.
/// </summary>
[Fact]
public void Rsv_WiderRange_ProducesHigherVariance()
{
// Narrow range bar
double narrowVar = ComputeRsVariance(100, 101, 99, 100);
// Wide range bar
double wideVar = ComputeRsVariance(100, 110, 90, 100);
Assert.True(wideVar > narrowVar,
"Wider range should produce higher RS variance");
}
/// <summary>
/// Validates that RSV uses all OHLC prices (unlike HLV which only uses H-L).
/// </summary>
[Fact]
public void Rsv_UsesAllOhlc_SensitiveToOpenClose()
{
var rsv1 = new Rsv(14, annualize: false);
var rsv2 = new Rsv(14, annualize: false);
for (int i = 0; i < 30; i++)
{
// Same high/low range but different open/close
// Indicator 1: doji pattern (open ≈ close at center)
var bar1 = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 105.0, 95.0, 100.0, 1000.0
);
rsv1.Update(bar1);
// Indicator 2: open and close at extremes
var bar2 = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
95.5, 105.0, 95.0, 104.5, 1000.0
);
rsv2.Update(bar2);
}
// RSV should be different since it uses all OHLC prices
Assert.NotEqual(rsv1.Last.Value, rsv2.Last.Value);
}
/// <summary>
/// Validates drift adjustment property: RSV handles trending markets.
/// </summary>
[Fact]
public void Rsv_DriftAdjusted_HandlesTrendingMarket()
{
var rsv = new Rsv(14, annualize: false);
// Strongly trending market (continuous up moves)
for (int i = 0; i < 30; i++)
{
double basePrice = 100 + i * 2; // Strong uptrend
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
basePrice, basePrice + 3, basePrice - 2, basePrice + 2, 1000.0
);
rsv.Update(bar);
}
// RSV should still produce valid volatility estimate
Assert.True(double.IsFinite(rsv.Last.Value));
Assert.True(rsv.Last.Value > 0, "Trending market with volatility should have positive RSV");
}
// === Consistency Tests ===
/// <summary>
/// Validates streaming and batch produce identical results.
/// </summary>
[Fact]
public void Rsv_StreamingMatchesBatch()
{
var bars = GenerateTestData(100);
// Streaming calculation
var streamingRsv = new Rsv(14);
for (int i = 0; i < bars.Count; i++)
{
streamingRsv.Update(bars[i]);
}
// Batch calculation
var batchResult = Rsv.Calculate(bars, 14);
// Compare last values
Assert.Equal(batchResult.Last.Value, streamingRsv.Last.Value, 8);
}
/// <summary>
/// Validates TBarSeries input matches TBar streaming.
/// </summary>
[Fact]
public void Rsv_TBarSeriesInput_MatchesStreaming()
{
var bars = GenerateTestData(100);
// Streaming
var streamingRsv = new Rsv(14);
for (int i = 0; i < bars.Count; i++)
{
streamingRsv.Update(bars[i]);
}
// TBarSeries batch
var batchRsv = new Rsv(14);
var batchResult = batchRsv.Update(bars);
Assert.Equal(batchResult.Last.Value, streamingRsv.Last.Value, 10);
}
/// <summary>
/// Validates Span batch matches streaming.
/// </summary>
[Fact]
public void Rsv_SpanBatch_MatchesStreaming()
{
var bars = GenerateTestData(100);
// Streaming
var streamingRsv = new Rsv(14);
for (int i = 0; i < bars.Count; i++)
{
streamingRsv.Update(bars[i]);
}
// Extract OHLC arrays
var opens = new double[bars.Count];
var highs = new double[bars.Count];
var lows = new double[bars.Count];
var closes = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
opens[i] = bars[i].Open;
highs[i] = bars[i].High;
lows[i] = bars[i].Low;
closes[i] = bars[i].Close;
}
// Span batch
var output = new double[bars.Count];
Rsv.Batch(opens, highs, lows, closes, output, 14);
Assert.Equal(output[^1], streamingRsv.Last.Value, 10);
}
/// <summary>
/// Validates annualized output is scaled correctly.
/// </summary>
[Fact]
public void Rsv_Annualized_ScaledCorrectly()
{
var bars = GenerateTestData(50);
// Non-annualized
var rsvRaw = new Rsv(14, annualize: false);
// Annualized (default 252 periods)
var rsvAnn = new Rsv(14, annualize: true, annualPeriods: 252);
for (int i = 0; i < bars.Count; i++)
{
rsvRaw.Update(bars[i]);
rsvAnn.Update(bars[i]);
}
double expectedRatio = Math.Sqrt(252);
double actualRatio = rsvAnn.Last.Value / rsvRaw.Last.Value;
Assert.Equal(expectedRatio, actualRatio, 6);
}
// === Parameter Sensitivity ===
/// <summary>
/// Validates shorter period produces more responsive volatility.
/// </summary>
[Fact]
public void Rsv_ShorterPeriod_MoreResponsive()
{
var bars = GenerateTestData(50);
var rsvShort = new Rsv(5);
var rsvLong = new Rsv(20);
var shortResults = new List<double>();
var longResults = new List<double>();
for (int i = 0; i < bars.Count; i++)
{
rsvShort.Update(bars[i]);
rsvLong.Update(bars[i]);
if (rsvShort.IsHot && rsvLong.IsHot)
{
shortResults.Add(rsvShort.Last.Value);
longResults.Add(rsvLong.Last.Value);
}
}
// Shorter period should have higher variance in results
double shortVar = Variance(shortResults);
double longVar = Variance(longResults);
Assert.True(shortResults.Count > 0, "Should have hot results");
Assert.True(shortVar > longVar * 0.5,
"Shorter period should generally be more variable");
}
/// <summary>
/// Validates different periods produce different results.
/// </summary>
[Fact]
public void Rsv_DifferentPeriods_ProduceDifferentResults()
{
var bars = GenerateTestData(50);
var rsv10 = new Rsv(10);
var rsv14 = new Rsv(14);
var rsv20 = new Rsv(20);
for (int i = 0; i < bars.Count; i++)
{
rsv10.Update(bars[i]);
rsv14.Update(bars[i]);
rsv20.Update(bars[i]);
}
Assert.NotEqual(rsv10.Last.Value, rsv14.Last.Value);
Assert.NotEqual(rsv14.Last.Value, rsv20.Last.Value);
}
// === Edge Cases ===
/// <summary>
/// Validates handling of very small ranges (tight consolidation).
/// </summary>
[Fact]
public void Rsv_VerySmallRanges_HandledCorrectly()
{
var rsv = new Rsv(14);
for (int i = 0; i < 30; i++)
{
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 100.001, 99.999, 100.0, 1000.0
);
rsv.Update(bar);
}
Assert.True(double.IsFinite(rsv.Last.Value));
Assert.True(rsv.Last.Value >= 0, "Volatility should be non-negative");
}
/// <summary>
/// Validates handling of very large ranges (high volatility).
/// </summary>
[Fact]
public void Rsv_VeryLargeRanges_HandledCorrectly()
{
var rsv = new Rsv(14);
for (int i = 0; i < 30; i++)
{
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 200.0, 50.0, 150.0, 1000.0
);
rsv.Update(bar);
}
Assert.True(double.IsFinite(rsv.Last.Value));
Assert.True(rsv.Last.Value > 0, "High volatility should produce positive value");
}
/// <summary>
/// Validates handling of constant bars (zero volatility).
/// </summary>
[Fact]
public void Rsv_ConstantBars_ProducesMinimalVolatility()
{
var rsv = new Rsv(14);
for (int i = 0; i < 30; i++)
{
// Near-constant bars (small epsilon to avoid log issues)
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 100.001, 99.999, 100.0, 1000.0
);
rsv.Update(bar);
}
Assert.True(double.IsFinite(rsv.Last.Value));
Assert.True(rsv.Last.Value < 0.01, "Near-constant price should produce near-zero volatility");
}
/// <summary>
/// Validates warmup period calculation.
/// </summary>
[Theory]
[InlineData(10)]
[InlineData(14)]
[InlineData(20)]
public void Rsv_WarmupPeriod_IsCorrect(int period)
{
var rsv = new Rsv(period);
Assert.Equal(period, rsv.WarmupPeriod);
}
/// <summary>
/// Validates output is always non-negative (volatility property).
/// </summary>
[Fact]
public void Rsv_Output_IsNonNegative()
{
var bars = GenerateTestData(100);
var rsv = new Rsv(14);
for (int i = 0; i < bars.Count; i++)
{
rsv.Update(bars[i]);
if (rsv.IsHot)
{
Assert.True(rsv.Last.Value >= 0,
$"Volatility should be non-negative at bar {i}");
}
}
}
/// <summary>
/// Validates bar correction works correctly.
/// </summary>
[Fact]
public void Rsv_BarCorrection_WorksCorrectly()
{
var rsv = new Rsv(14);
var bars = GenerateTestData(30);
// Feed initial bars
for (int i = 0; i < 20; i++)
{
rsv.Update(bars[i], isNew: true);
}
// Add new bar
rsv.Update(bars[20], isNew: true);
double afterNew = rsv.Last.Value;
// Correct with different bar (much higher volatility)
var correctedBar = new TBar(
bars[20].Time,
100, 200, 50, 150, 1000
);
rsv.Update(correctedBar, isNew: false);
double afterCorrection = rsv.Last.Value;
// Restore original
rsv.Update(bars[20], isNew: false);
double afterRestore = rsv.Last.Value;
Assert.NotEqual(afterNew, afterCorrection);
Assert.Equal(afterNew, afterRestore, 10);
}
/// <summary>
/// Validates iterative corrections converge to same result.
/// </summary>
[Fact]
public void Rsv_IterativeCorrections_Converge()
{
var rsv = new Rsv(14);
var bars = GenerateTestData(30);
// Feed bars and make corrections
for (int i = 0; i < 20; i++)
{
rsv.Update(bars[i], isNew: true);
}
// Multiple corrections on same bar
for (int j = 0; j < 5; j++)
{
var tempBar = new TBar(
bars[19].Time,
100 + j, 110 + j, 90 + j, 105 + j, 1000
);
rsv.Update(tempBar, isNew: false);
}
// Final correction back to original
rsv.Update(bars[19], isNew: false);
double afterCorrections = rsv.Last.Value;
// Fresh calculation
var rsvFresh = new Rsv(14);
for (int i = 0; i < 20; i++)
{
rsvFresh.Update(bars[i], isNew: true);
}
double freshValue = rsvFresh.Last.Value;
Assert.Equal(freshValue, afterCorrections, 10);
}
// === Comparison with Other Volatility Estimators ===
/// <summary>
/// Validates RSV vs HLV: RSV uses O-C, HLV ignores O-C.
/// </summary>
[Fact]
public void Rsv_VsHlv_DifferentBehavior()
{
var rsv = new Rsv(14, annualize: false);
var hlv = new Hlv(14, annualize: false);
// Same bars
for (int i = 0; i < 30; i++)
{
// Directional bar (O != C)
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 105.0, 95.0, 104.0, 1000.0
);
rsv.Update(bar);
hlv.Update(bar);
}
// Both should produce positive values
Assert.True(rsv.Last.Value > 0);
Assert.True(hlv.Last.Value > 0);
// They should be different since RSV uses O-C while HLV ignores it
Assert.NotEqual(rsv.Last.Value, hlv.Last.Value);
}
/// <summary>
/// Validates RSV vs GKV: both use OHLC but different formulas.
/// </summary>
[Fact]
public void Rsv_VsGkv_DifferentValues()
{
var rsv = new Rsv(14, annualize: false);
var gkv = new Gkv(14, annualize: false);
var bars = GenerateTestData(50);
for (int i = 0; i < bars.Count; i++)
{
rsv.Update(bars[i]);
gkv.Update(bars[i]);
}
// Both should produce positive values
Assert.True(rsv.Last.Value > 0);
Assert.True(gkv.Last.Value > 0);
// They should be similar but not identical (different formulas)
Assert.NotEqual(rsv.Last.Value, gkv.Last.Value);
}
// === Stability Tests ===
/// <summary>
/// Validates RSV stability over repeated runs with same seed.
/// </summary>
[Fact]
public void Rsv_Stability_ConsistentOverRepeatedRuns()
{
// Multiple runs with same seed should produce identical results
var results = new List<double>();
for (int run = 0; run < 3; run++)
{
var gbm = new GBM(seed: 42);
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var rsv = new Rsv(14);
for (int i = 0; i < bars.Count; i++)
{
rsv.Update(bars[i]);
}
results.Add(rsv.Last.Value);
}
// All runs should be identical
Assert.Equal(results[0], results[1], 15);
Assert.Equal(results[1], results[2], 15);
}
/// <summary>
/// Validates RSV responds to volatility regime changes.
/// </summary>
[Fact]
public void Rsv_RespondsToVolatilityRegimeChange()
{
var rsv = new Rsv(10);
// Low volatility regime
for (int i = 0; i < 20; i++)
{
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 101.0, 99.0, 100.5, 1000.0 // 2% range
);
rsv.Update(bar);
}
double lowVolValue = rsv.Last.Value;
// High volatility regime
for (int i = 20; i < 40; i++)
{
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 110.0, 90.0, 105.0, 1000.0 // 20% range
);
rsv.Update(bar);
}
double highVolValue = rsv.Last.Value;
Assert.True(highVolValue > lowVolValue * 2,
"RSV should significantly increase with higher volatility regime");
}
/// <summary>
/// Validates RSV produces reasonable volatility estimate.
/// </summary>
[Fact]
public void Rsv_ProducesReasonableVolatilityEstimate()
{
var bars = GenerateTestData(100);
var rsv = new Rsv(14, annualize: false);
for (int i = 0; i < bars.Count; i++)
{
rsv.Update(bars[i]);
}
// RSV should be positive and finite
Assert.True(double.IsFinite(rsv.Last.Value));
Assert.True(rsv.Last.Value > 0);
Assert.True(rsv.Last.Value < 10, "Raw volatility should be reasonable (< 1000%)");
}
// === SMA vs RMA Smoothing Validation ===
/// <summary>
/// Validates that RSV uses SMA (not RMA like HLV).
/// SMA should adapt faster to changes when period is small.
/// </summary>
[Fact]
public void Rsv_SmaSmoothing_AdaptsToChange()
{
var rsv = new Rsv(5, annualize: false);
// Low volatility phase
for (int i = 0; i < 10; i++)
{
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 101.0, 99.0, 100.0, 1000.0
);
rsv.Update(bar);
}
double lowVolValue = rsv.Last.Value;
// Sudden high volatility (5 bars = full SMA window)
for (int i = 10; i < 15; i++)
{
var bar = new TBar(
DateTime.UtcNow.AddMinutes(i).Ticks,
100.0, 120.0, 80.0, 100.0, 1000.0
);
rsv.Update(bar);
}
double afterHighVolSma = rsv.Last.Value;
// With SMA (period=5), after 5 high-vol bars the old low-vol values should be gone
// Value should be significantly higher
Assert.True(afterHighVolSma > lowVolValue * 3,
"SMA should fully adapt after period bars");
}
// === Helper Methods ===
private static double ComputeRsVariance(double open, double high, double low, double close)
{
// Protect against division by zero
open = Math.Max(open, 1e-10);
close = Math.Max(close, 1e-10);
double lnHO = Math.Log(high / open);
double lnHC = Math.Log(high / close);
double lnLO = Math.Log(low / open);
double lnLC = Math.Log(low / close);
return lnHO * lnHC + lnLO * lnLC;
}
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));
}
}
+599
View File
@@ -0,0 +1,599 @@
// Rogers-Satchell Volatility (RSV) Indicator
// A drift-adjusted OHLC volatility estimator using SMA smoothing
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// RSV: Rogers-Satchell Volatility
/// A drift-adjusted volatility estimator that uses all four OHLC prices,
/// providing more accurate estimates in trending markets than range-based methods.
/// </summary>
/// <remarks>
/// <b>Calculation steps:</b>
/// <list type="number">
/// <item>Calculate log ratios: term1=ln(H/O), term2=ln(H/C), term3=ln(L/O), term4=ln(L/C)</item>
/// <item>rsVariance = (term1 × term2) + (term3 × term4)</item>
/// <item>Smooth using Simple Moving Average (SMA)</item>
/// <item>volatility = √(max(0, smoothedVariance))</item>
/// <item>If annualize: volatility × √(annualPeriods)</item>
/// </list>
///
/// <b>Key characteristics:</b>
/// <list type="bullet">
/// <item>Uses all OHLC data for drift adjustment</item>
/// <item>SMA smoothing for stability</item>
/// <item>Optional annualization (default 252 trading days)</item>
/// <item>Handles trending markets better than Parkinson/GK</item>
/// </list>
///
/// <b>Sources:</b>
/// Rogers, L.C.G. and Satchell, S.E. (1991). "Estimating Variance from High, Low and Closing Prices."
/// Annals of Applied Probability, 1(4), 504-512.
/// </remarks>
[SkipLocalsInit]
public sealed class Rsv : AbstractBase
{
private const double Epsilon = 1e-10;
private readonly int _period;
private readonly bool _annualize;
private readonly int _annualPeriods;
private readonly double _annualFactor;
// Circular buffer for SMA
private readonly double[] _buffer;
private readonly double[] _bufferSnapshot;
// Event source for disposal
private readonly ITValuePublisher? _source;
[StructLayout(LayoutKind.Auto)]
private record struct State(
double Sum,
double LastValidRsVar,
double LastValue,
int Count,
int BufferIdx
);
private State _s;
private State _ps;
/// <summary>
/// Initializes a new instance of the Rsv class.
/// </summary>
/// <param name="period">The smoothing period (default 20).</param>
/// <param name="annualize">Whether to annualize the volatility (default true).</param>
/// <param name="annualPeriods">Number of periods per year (default 252).</param>
/// <exception cref="ArgumentException">
/// Thrown when period is less than 1, or annualPeriods is less than 1 when annualizing.
/// </exception>
public Rsv(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;
_annualFactor = annualize ? Math.Sqrt(annualPeriods) : 1.0;
_buffer = new double[period];
_bufferSnapshot = new double[period];
WarmupPeriod = period;
Name = $"Rsv({period})";
_s = new State(0, 0, 0, 0, 0);
_ps = _s;
}
/// <summary>
/// Initializes a new instance of the Rsv class with a source.
/// </summary>
/// <param name="source">The data source for chaining.</param>
/// <param name="period">The smoothing period (default 20).</param>
/// <param name="annualize">Whether to annualize the volatility (default true).</param>
/// <param name="annualPeriods">Number of periods per year (default 252).</param>
public Rsv(ITValuePublisher source, int period = 20, bool annualize = true, int annualPeriods = 252)
: this(period, annualize, annualPeriods)
{
_source = source;
_source.Pub += Handle;
}
private void Handle(object? sender, in TValueEventArgs e) => Update(e.Value, e.IsNew);
/// <summary>
/// True if the indicator has enough data for valid results.
/// </summary>
public override bool IsHot => _s.Count >= WarmupPeriod;
/// <summary>
/// The smoothing period.
/// </summary>
public int Period => _period;
/// <summary>
/// Whether volatility is annualized.
/// </summary>
public bool Annualize => _annualize;
/// <summary>
/// Number of periods per year for annualization.
/// </summary>
public int AnnualPeriods => _annualPeriods;
/// <summary>
/// Computes the Rogers-Satchell variance for a single bar.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double ComputeRsVariance(double open, double high, double low, double close)
{
// Protect against zero/negative prices
double o = Math.Max(open, Epsilon);
double h = Math.Max(high, Epsilon);
double l = Math.Max(low, Epsilon);
double c = Math.Max(close, Epsilon);
double term1 = Math.Log(h / o);
double term2 = Math.Log(h / c);
double term3 = Math.Log(l / o);
double term4 = Math.Log(l / c);
// rs_variance = (term1 * term2) + (term3 * term4)
return Math.FusedMultiplyAdd(term1, term2, term3 * term4);
}
/// <summary>
/// Updates the indicator with a TValue input.
/// For RSV, this treats the value as a pre-computed RS variance.
/// Prefer Update(TBar) for standard OHLC data.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public override TValue Update(TValue input, bool isNew = true)
{
return UpdateCore(input.Time, input.Value, isNew);
}
/// <summary>
/// Updates the indicator with a new bar (preferred method).
/// </summary>
/// <param name="bar">The input bar.</param>
/// <param name="isNew">Whether this is a new bar or an update.</param>
/// <returns>The calculated volatility value.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar bar, bool isNew = true)
{
// Handle invalid OHLC data
if (!double.IsFinite(bar.Open) || !double.IsFinite(bar.High) ||
!double.IsFinite(bar.Low) || !double.IsFinite(bar.Close) ||
bar.Open <= 0 || bar.High <= 0 || bar.Low <= 0 || bar.Close <= 0)
{
// Pass NaN to trigger last-valid-value substitution
return UpdateCore(bar.Time, double.NaN, isNew);
}
double rsVariance = ComputeRsVariance(bar.Open, bar.High, bar.Low, bar.Close);
return UpdateCore(bar.Time, rsVariance, isNew);
}
/// <summary>
/// Updates the indicator with a bar series.
/// </summary>
/// <param name="source">The source bar series.</param>
/// <returns>A TSeries containing the volatility values.</returns>
public TSeries Update(TBarSeries source)
{
if (source.Count == 0)
{
return [];
}
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
var tSpan = CollectionsMarshal.AsSpan(t);
var vSpan = CollectionsMarshal.AsSpan(v);
// Extract OHLC data
Span<double> opens = len <= 128 ? stackalloc double[len] : new double[len];
Span<double> highs = len <= 128 ? stackalloc double[len] : new double[len];
Span<double> lows = len <= 128 ? stackalloc double[len] : new double[len];
Span<double> closes = len <= 128 ? stackalloc double[len] : new double[len];
for (int i = 0; i < len; i++)
{
opens[i] = source[i].Open;
highs[i] = source[i].High;
lows[i] = source[i].Low;
closes[i] = source[i].Close;
tSpan[i] = source[i].Time;
}
Batch(opens, highs, lows, closes, vSpan, _period, _annualize, _annualPeriods);
// Update internal state
for (int i = 0; i < len; i++)
{
Update(source[i], isNew: true);
}
return new TSeries(t, v);
}
/// <inheritdoc/>
public override TSeries Update(TSeries source)
{
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);
// Treat source values as pre-computed RS variances
BatchFromVariances(source.Values, vSpan, _period, _annualize, _annualPeriods);
source.Times.CopyTo(tSpan);
// Update internal state
for (int i = 0; i < len; i++)
{
Update(new TValue(source.Times[i], source.Values[i]), isNew: true);
}
return new TSeries(t, v);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private TValue UpdateCore(long timeTicks, double rsVariance, bool isNew)
{
if (isNew)
{
_ps = _s;
// Snapshot buffer state for potential rollback
Array.Copy(_buffer, _bufferSnapshot, _period);
}
else
{
_s = _ps;
// Restore buffer from snapshot
Array.Copy(_bufferSnapshot, _buffer, _period);
}
var s = _s;
// Handle non-finite variance - use last valid value
if (!double.IsFinite(rsVariance))
{
rsVariance = s.LastValidRsVar;
}
else
{
s.LastValidRsVar = rsVariance;
}
// SMA with circular buffer
double sum = s.Sum;
int bufferIdx = s.BufferIdx;
int count = s.Count;
// Both isNew=true and isNew=false follow the same calculation logic after state restore:
// - If count >= period, remove the old value at bufferIdx from sum
// - Add new value to sum
// - Write new value to buffer[bufferIdx]
// - Increment bufferIdx and count
// The only difference: isNew=true also saves state to _ps before processing
if (count >= _period)
{
// Remove oldest value from sum (the value at current bufferIdx position)
sum -= _buffer[bufferIdx];
}
// Add new value to sum and buffer
sum += rsVariance;
_buffer[bufferIdx] = rsVariance;
// Always advance the buffer position and count
bufferIdx = (bufferIdx + 1) % _period;
count++;
// Calculate SMA
int effectiveCount = Math.Min(count, _period);
double smaVariance = effectiveCount > 0 ? sum / effectiveCount : 0;
// Calculate volatility: sqrt(max(0, smaVariance))
double volatility = smaVariance > 0 ? Math.Sqrt(smaVariance) * _annualFactor : 0;
if (!double.IsFinite(volatility))
{
volatility = s.LastValue;
}
// Update state - always update _s with the new values
s.Sum = sum;
s.BufferIdx = bufferIdx;
s.Count = count;
s.LastValue = volatility;
_s = s;
Last = new TValue(timeTicks, volatility);
PubEvent(Last, isNew);
return Last;
}
/// <inheritdoc/>
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
for (int i = 0; i < source.Length; i++)
{
Update(new TValue(DateTime.UtcNow, source[i]), isNew: true);
}
}
/// <inheritdoc/>
public override void Reset()
{
_s = new State(0, 0, 0, 0, 0);
_ps = _s;
Array.Clear(_buffer);
Array.Clear(_bufferSnapshot);
Last = default;
}
/// <summary>
/// Releases resources and unsubscribes from the event source.
/// </summary>
/// <param name="disposing">True if disposing managed resources.</param>
protected override void Dispose(bool disposing)
{
if (disposing && _source is not null)
{
_source.Pub -= Handle;
}
base.Dispose(disposing);
}
/// <summary>
/// Calculates Rogers-Satchell Volatility for a bar series (static).
/// </summary>
/// <param name="source">The source bar series.</param>
/// <param name="period">The smoothing period.</param>
/// <param name="annualize">Whether to annualize.</param>
/// <param name="annualPeriods">Periods per year.</param>
/// <returns>A TSeries containing the volatility values.</returns>
public static TSeries Calculate(TBarSeries source, int period = 20, bool annualize = true, int annualPeriods = 252)
{
var rsv = new Rsv(period, annualize, annualPeriods);
return rsv.Update(source);
}
/// <summary>
/// Calculates RSV for a TSeries (treats values as pre-computed RS variances).
/// </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);
BatchFromVariances(source.Values, vSpan, period, annualize, annualPeriods);
source.Times.CopyTo(tSpan);
return new TSeries(t, v);
}
/// <summary>
/// Batch calculation using spans for OHLC data.
/// </summary>
/// <param name="open">Open prices.</param>
/// <param name="high">High prices.</param>
/// <param name="low">Low prices.</param>
/// <param name="close">Close prices.</param>
/// <param name="output">Output volatility values.</param>
/// <param name="period">The smoothing period.</param>
/// <param name="annualize">Whether to annualize.</param>
/// <param name="annualPeriods">Periods per year.</param>
public static void Batch(
ReadOnlySpan<double> open,
ReadOnlySpan<double> high,
ReadOnlySpan<double> low,
ReadOnlySpan<double> close,
Span<double> output,
int period = 20,
bool annualize = true,
int annualPeriods = 252)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
if (annualize && annualPeriods <= 0)
{
throw new ArgumentException("Annual periods must be greater than 0 when annualizing", nameof(annualPeriods));
}
int len = open.Length;
if (high.Length != len || low.Length != len || close.Length != len)
{
throw new ArgumentException("All OHLC spans must have the same length", nameof(close));
}
if (output.Length < len)
{
throw new ArgumentException("Output span must be at least as long as input spans", nameof(output));
}
if (len == 0)
{
return;
}
double annualFactor = annualize ? Math.Sqrt(annualPeriods) : 1.0;
// SMA circular buffer
Span<double> buffer = period <= 256 ? stackalloc double[period] : new double[period];
double sum = 0;
int bufferIdx = 0;
double lastValidRsVar = 0;
double lastValue = 0;
for (int i = 0; i < len; i++)
{
double o = open[i];
double h = high[i];
double l = low[i];
double c = close[i];
double rsVariance;
// Handle invalid data
if (!double.IsFinite(o) || !double.IsFinite(h) ||
!double.IsFinite(l) || !double.IsFinite(c) ||
o <= 0 || h <= 0 || l <= 0 || c <= 0)
{
rsVariance = lastValidRsVar;
}
else
{
rsVariance = ComputeRsVariance(o, h, l, c);
if (!double.IsFinite(rsVariance))
{
rsVariance = lastValidRsVar;
}
else
{
lastValidRsVar = rsVariance;
}
}
// SMA update
if (i >= period)
{
sum -= buffer[bufferIdx];
}
sum += rsVariance;
buffer[bufferIdx] = rsVariance;
bufferIdx = (bufferIdx + 1) % period;
int effectiveCount = Math.Min(i + 1, period);
double smaVariance = sum / effectiveCount;
double volatility = smaVariance > 0 ? Math.Sqrt(smaVariance) * annualFactor : 0;
if (!double.IsFinite(volatility))
{
volatility = lastValue;
}
else
{
lastValue = volatility;
}
output[i] = volatility;
}
}
/// <summary>
/// Batch calculation from pre-computed RS variances.
/// </summary>
private static void BatchFromVariances(
ReadOnlySpan<double> variances,
Span<double> output,
int period,
bool annualize,
int annualPeriods)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0", nameof(period));
}
if (variances.Length != output.Length)
{
throw new ArgumentException("Source and output must have the same length", nameof(output));
}
int len = variances.Length;
if (len == 0)
{
return;
}
double annualFactor = annualize ? Math.Sqrt(annualPeriods) : 1.0;
// SMA circular buffer
Span<double> buffer = period <= 256 ? stackalloc double[period] : new double[period];
double sum = 0;
int bufferIdx = 0;
double lastValidRsVar = 0;
double lastValue = 0;
for (int i = 0; i < len; i++)
{
double rsVariance = variances[i];
if (!double.IsFinite(rsVariance))
{
rsVariance = lastValidRsVar;
}
else
{
lastValidRsVar = rsVariance;
}
// SMA update
if (i >= period)
{
sum -= buffer[bufferIdx];
}
sum += rsVariance;
buffer[bufferIdx] = rsVariance;
bufferIdx = (bufferIdx + 1) % period;
int effectiveCount = Math.Min(i + 1, period);
double smaVariance = sum / effectiveCount;
double volatility = smaVariance > 0 ? Math.Sqrt(smaVariance) * annualFactor : 0;
if (!double.IsFinite(volatility))
{
volatility = lastValue;
}
else
{
lastValue = volatility;
}
output[i] = volatility;
}
}
}
+339
View File
@@ -0,0 +1,339 @@
# RSV: Rogers-Satchell Volatility
> "The best estimator is one that extracts maximum information from all available data while remaining robust to the noise of market microstructure."
Rogers-Satchell Volatility (RSV) is a drift-adjusted OHLC-based volatility estimator that uses all four price points (Open, High, Low, Close) to provide more accurate volatility estimates than simpler range-based methods. Developed by L.C.G. Rogers and S.E. Satchell in 1991, this estimator is unique in its ability to account for price drift, making it particularly suitable for trending markets. The implementation uses SMA smoothing and optional annualization.
## Historical Context
Rogers and Satchell introduced this estimator in their 1991 paper "Estimating Variance from High, Low and Closing Prices" published in the Annals of Applied Probability. The key innovation was recognizing that the Parkinson and Garman-Klass estimators assume zero drift (no trend), which can introduce bias during trending markets.
The Rogers-Satchell estimator was designed to be independent of the drift rate, meaning it provides unbiased variance estimates regardless of whether the underlying asset is trending up, down, or sideways. This makes it theoretically superior for real-world markets where trends are common.
The estimator achieves approximately 8.4x the efficiency of close-to-close methods, making it one of the more efficient OHLC-based estimators available. It sits between Garman-Klass (7.4x) and Yang-Zhang (14x) in the efficiency hierarchy.
Unlike Parkinson (HLV) which uses only High-Low, or some implementations that focus on the close-to-close return, RSV extracts information from all four price relationships simultaneously: H/O, H/C, L/O, and L/C.
## Architecture & Physics
### 1. Log Price Ratios
All calculations use log ratios to normalize percentage returns:
$$
\ln\frac{H_t}{O_t}, \ln\frac{H_t}{C_t}, \ln\frac{L_t}{O_t}, \ln\frac{L_t}{C_t}
$$
where:
- $O_t, H_t, L_t, C_t$ = Open, High, Low, Close prices at time $t$
Log transformation ensures that equal percentage moves have equal magnitude regardless of price level.
### 2. Rogers-Satchell Variance
The single-period Rogers-Satchell variance estimator:
$$
\hat{\sigma}^2_{RS,t} = \ln\frac{H_t}{O_t} \cdot \ln\frac{H_t}{C_t} + \ln\frac{L_t}{O_t} \cdot \ln\frac{L_t}{C_t}
$$
This formula has a beautiful symmetry: both terms are products of log ratios involving the high or low price against both open and close.
**Key Property:** For valid OHLC data, this variance estimate is always non-negative:
- The first term: $\ln(H/O) \geq 0$ and $\ln(H/C) \geq 0$ (since $H \geq O$ and $H \geq C$)
- The second term: $\ln(L/O) \leq 0$ and $\ln(L/C) \leq 0$ (since $L \leq O$ and $L \leq C$)
- Both products are non-negative, so the sum is non-negative
### 3. SMA Smoothing
Unlike HLV and GKV which use RMA (Wilder's) smoothing, RSV uses a Simple Moving Average with a circular buffer:
$$
SMA_t = \frac{1}{n} \sum_{i=t-n+1}^{t} \hat{\sigma}^2_{RS,i}
$$
where $n$ = period (default 20).
SMA provides:
- Equal weighting of all observations in the window
- Complete adaptation after exactly $n$ bars
- No exponential decay bias requiring correction
### 4. Volatility Calculation
Convert smoothed variance to volatility (standard deviation):
$$
\sigma_t = \sqrt{\max(0, SMA_t)}
$$
The max(0, ...) guard protects against potential floating-point artifacts.
### 5. Optional Annualization
If annualization is enabled (default):
$$
\sigma_{annual,t} = \sigma_t \times \sqrt{N}
$$
where $N$ = annual periods (default 252 trading days).
## Mathematical Foundation
### Drift Independence
The Rogers-Satchell estimator's key mathematical property is its independence from drift. For a geometric Brownian motion:
$$
dS = \mu S dt + \sigma S dW
$$
The standard close-to-close variance estimator:
$$
\hat{\sigma}^2_{CC} = (\ln C_t - \ln C_{t-1})^2
$$
includes the drift term $\mu dt$, biasing the estimate.
The Rogers-Satchell formula, through its specific combination of high-low-open-close ratios, cancels out the drift component mathematically, yielding an unbiased estimate of $\sigma^2$ regardless of $\mu$.
### Why This Formula Works
Consider the log price path within a single bar:
- The high and low represent extrema of the Brownian path
- The open-to-high and close-to-high paths share the high point
- The open-to-low and close-to-low paths share the low point
The product structure $\ln(H/O) \cdot \ln(H/C)$ captures the "spread" between how far the high was from both boundaries (open and close). Similarly for the low. This geometric information is invariant to drift.
### Comparison of Variance Formulas
| Estimator | Formula | Drift Adjustment |
| :--- | :--- | :---: |
| Close-to-Close | $(\ln C/C_{prev})^2$ | None |
| Parkinson | $\frac{1}{4\ln 2}(\ln H/L)^2$ | None |
| Garman-Klass | $\frac{1}{2}(\ln H/L)^2 - (2\ln 2-1)(\ln C/O)^2$ | Partial |
| **Rogers-Satchell** | $\ln(H/O)\ln(H/C) + \ln(L/O)\ln(L/C)$ | **Full** |
| Yang-Zhang | RS + overnight + open-close | Full |
### Efficiency Comparison
| Estimator | Relative Efficiency | Data Required |
| :--- | :---: | :--- |
| Close-to-Close | 1.0 | C |
| Parkinson (HLV) | 5.2 | H, L |
| Garman-Klass (GKV) | 7.4 | O, H, L, C |
| **Rogers-Satchell (RSV)** | **8.4** | **O, H, L, C** |
| Yang-Zhang | 14.0 | O, H, L, C (+ prev C) |
RSV achieves 8.4x the efficiency of close-to-close, meaning it produces the same statistical precision with 8.4x fewer observations.
### SMA Properties
**Period Characteristics:**
| Period | Window Size | Adaptation Time |
| :---: | :---: | :---: |
| 10 | 10 bars | Complete after 10 bars |
| 14 | 14 bars | Complete after 14 bars |
| 20 | 20 bars | Complete after 20 bars |
SMA (unlike RMA) has no exponential tail — old values are completely dropped after exactly $period$ bars.
### Annualization Factor
For daily data with 252 trading days:
$$
\sqrt{252} \approx 15.875
$$
Common annualization factors:
| Data Frequency | Periods/Year | Factor |
| :--- | :---: | :---: |
| Daily | 252 | 15.875 |
| Weekly | 52 | 7.211 |
| Monthly | 12 | 3.464 |
| Hourly (6.5h/day) | 1638 | 40.472 |
## Performance Profile
### Operation Count (Streaming Mode, Scalar)
Per-bar operations after warmup:
| Operation | Count | Cost (cycles) | Subtotal |
| :--- | :---: | :---: | :---: |
| DIV | 4 | 15 | 60 |
| LOG | 4 | 25 | 100 |
| MUL | 2 | 3 | 6 |
| ADD | 1 | 1 | 1 |
| Buffer update | 1 | 3 | 3 |
| SMA sum | 1 | 5 | 5 |
| DIV (SMA) | 1 | 15 | 15 |
| SQRT | 1 | 15 | 15 |
| MUL (annual) | 1 | 3 | 3 |
| **Total** | — | — | **~208 cycles** |
The dominant cost is the four LOG operations (48% of total). RSV is ~40% slower than HLV but provides drift independence.
### Batch Mode (512 values, SIMD/FMA)
| Operation | Scalar Ops | SIMD Ops (AVX2) | Speedup |
| :--- | :---: | :---: | :---: |
| LOG (vectorized) | 2048 | 256 | 8× |
| DIV (vectorized) | 2048 | 256 | 8× |
| MUL/ADD (FMA) | 1536 | 192 | 8× |
| SMA (sliding sum) | 512 | 512 | 1× |
| SQRT (vectorized) | 512 | 64 | 8× |
**Note:** SMA computation can be optimized with a rolling sum (O(1) per bar) but is inherently sequential for the running state.
### Memory Profile
- **Per instance:** ~88 + 8×period bytes (state + circular buffer)
- **With period=20:** ~248 bytes
- **100 instances (period=20):** ~24.8 KB
### Quality Metrics
| Metric | Score | Notes |
| :--- | :---: | :--- |
| **Accuracy** | 9/10 | Drift-adjusted, unbiased |
| **Efficiency** | 8/10 | 8.4x better than close-to-close |
| **Timeliness** | 8/10 | SMA provides faster adaptation than RMA |
| **Smoothness** | 7/10 | SMA can be jumpier than RMA |
| **Robustness** | 8/10 | Handles trends well |
## Validation
RSV is well-documented in academic literature but implementations vary:
| 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 rsv.pine reference |
| **Manual** | ✅ | Validated against original paper formula |
The implementation is validated against the original Rogers-Satchell 1991 paper formula.
## Common Pitfalls
1. **Warmup period**: RSV requires $period$ bars before producing stable results. With default period=20, the first 19 values are warming up. The `IsHot` property indicates when warmup is complete.
2. **OHLC data quality**: RSV requires all four OHLC prices. Missing or invalid data (high < low, prices ≤ 0) will trigger last-valid-value substitution. Ensure data quality before feeding to RSV.
3. **Zero prices**: Log ratios require positive prices. Prices of zero are clamped to a small epsilon (1e-10) to prevent log(0) = -∞.
4. **Annualization assumption**: Default annualization assumes 252 trading days/year. For other frequencies (hourly, weekly), adjust the `annualPeriods` parameter accordingly.
5. **SMA vs RMA**: Unlike HLV/GKV which use RMA, RSV uses SMA. This means:
- Complete adaptation after exactly $period$ bars (faster)
- No bias correction needed
- Can be "jumpier" when old high/low variance values drop out of the window
6. **Comparison with HLV**: HLV only uses High-Low, ignoring Open-Close. RSV uses all four prices and is drift-adjusted. Use RSV when OHLC data is available and markets are trending; use HLV when only High-Low data exists.
7. **Comparison with GKV**: Both use OHLC, but RSV is fully drift-adjusted while GKV is only partially. RSV is slightly more efficient (8.4x vs 7.4x) and better for trending markets.
## Trading Applications
### Position Sizing
Use RSV to scale position sizes inversely with volatility:
```
Position size = Risk per trade / (RSV × Price × multiplier)
```
Lower RSV allows larger positions; higher RSV requires smaller positions. RSV's drift adjustment makes it more reliable during trends.
### Volatility Regime Detection
Track RSV percentile rank over lookback period:
```
High rank (>80%): High volatility regime — reduce position size, widen stops
Low rank (<20%): Low volatility regime — potential for breakout
```
### Options Pricing Input
RSV provides a realized volatility estimate for comparison with implied volatility:
```
If IV > RSV significantly: Options may be overpriced (sell vol)
If IV < RSV significantly: Options may be underpriced (buy vol)
```
RSV's drift adjustment makes it preferable to HLV/GKV for options analysis during trending markets.
### Trend Quality Assessment
Compare RSV with simpler estimators during trends:
```
If RSV ≈ HLV: Low drift, range-bound market
If RSV < HLV: Significant drift (trending), HLV is overstating vol
```
### Stop-Loss Calibration
Use RSV to set dynamic stop-loss levels:
```
Stop distance = Entry price ± (RSV × K × Price)
where K is a multiplier (typically 1.5-3.0)
```
RSV's drift adjustment prevents stops from being set too wide during strong trends.
## Implementation Notes
### Circular Buffer for SMA
The implementation uses a fixed-size circular buffer for O(1) updates:
```csharp
// Buffer stores RS variance values
_buffer[_bufferIndex] = rsVariance;
_bufferIndex = (_bufferIndex + 1) % _period;
_bufferSum = _bufferSum - oldest + rsVariance;
```
This avoids O(n) summation on each update.
### Price Protection
All price ratios are protected against zero/negative values:
```csharp
open = Math.Max(open, 1e-10);
close = Math.Max(close, 1e-10);
```
### FMA Optimization
The variance calculation uses FMA where beneficial:
```csharp
rsVariance = Math.FusedMultiplyAdd(lnHO, lnHC, lnLO * lnLC);
```
## References
- Rogers, L. C. G., & Satchell, S. E. (1991). "Estimating Variance from High, Low and Closing Prices." *Annals of Applied Probability*, 1(4), 504-512.
- Parkinson, M. (1980). "The Extreme Value Method for Estimating the Variance of the Rate of Return." *Journal of Business*, 53(1), 61-65.
- Garman, M. B., & Klass, M. J. (1980). "On the Estimation of Security Price Volatilities from Historical Data." *Journal of Business*, 53(1), 67-78.
- Yang, D., & Zhang, Q. (2000). "Drift-Independent Volatility Estimation Based on High, Low, Open, and Close Prices." *Journal of Business*, 73(3), 477-492.
- Alizadeh, S., Brandt, M. W., & Diebold, F. X. (2002). "Range-Based Estimation of Stochastic Volatility Models." *Journal of Finance*, 57(3), 1047-1091.