mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-19 02:58:05 +00:00
Update SVG badges and missing indicators report
- Updated class count in classes.svg from 938 to 1078. - Adjusted comments percentage in comments.svg from 33.06 to 33.02. - Revised average cyclomatic complexity in complexity.svg from 2.19 to 2.12. - Increased source files count in files.svg from 1099 to 1275. - Updated lines of code in loc.svg from 114549 to 129859. - Increased methods count in methods.svg from 12035 to 14066. - Updated public types count in public-api.svg from 1086 to 1225. - Revised missing indicators report with updated counts and categories, reflecting recent implementations and planned additions.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using QuanTAlib;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class HurstIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void HurstIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new HurstIndicator();
|
||||
|
||||
Assert.Equal(100, indicator.Period);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("Hurst - Hurst Exponent", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
Assert.Equal(SourceType.Close, indicator.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HurstIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new HurstIndicator { Period = 100 };
|
||||
|
||||
Assert.Equal(0, HurstIndicator.MinHistoryDepths);
|
||||
IWatchlistIndicator watchlistIndicator = indicator;
|
||||
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HurstIndicator_Initialize_CreatesInternalHurst()
|
||||
{
|
||||
var indicator = new HurstIndicator { Period = 20 };
|
||||
|
||||
// Initialize should not throw
|
||||
indicator.Initialize();
|
||||
|
||||
// After init, line series should exist (Hurst line + 0.5 reference line)
|
||||
Assert.Equal(2, indicator.LinesSeries.Count);
|
||||
Assert.Equal("Hurst", indicator.LinesSeries[0].Name);
|
||||
Assert.Equal("0.5", indicator.LinesSeries[1].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HurstIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new HurstIndicator { Period = 20 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
|
||||
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
double hurst = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(hurst));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HurstIndicator_DifferentSourceTypes()
|
||||
{
|
||||
var indicator = new HurstIndicator { Period = 20, Source = SourceType.Open };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
double hurst = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(hurst));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HurstIndicator_ShortName_IncludesPeriod()
|
||||
{
|
||||
var indicator = new HurstIndicator { Period = 50 };
|
||||
Assert.Equal("Hurst 50", indicator.ShortName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HurstIndicator_NewBar_UpdatesValue()
|
||||
{
|
||||
var indicator = new HurstIndicator { Period = 20 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
_ = indicator.LinesSeries[0].GetValue(0);
|
||||
|
||||
// Add a new bar with a very different value
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(30), 200, 210, 190, 205);
|
||||
var newArgs = new UpdateArgs(UpdateReason.NewBar);
|
||||
indicator.ProcessUpdate(newArgs);
|
||||
|
||||
double valueAfter = indicator.LinesSeries[0].GetValue(0);
|
||||
Assert.True(double.IsFinite(valueAfter));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class HurstIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Period", sortIndex: 1, 20, 2000, 1, 0)]
|
||||
public int Period { get; set; } = 100;
|
||||
|
||||
[IndicatorExtensions.DataSourceInput]
|
||||
public SourceType Source { get; set; } = SourceType.Close;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Hurst _hurst = null!;
|
||||
private readonly LineSeries _series;
|
||||
private readonly LineSeries _halfLine;
|
||||
private Func<IHistoryItem, double> _priceSelector = null!;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"Hurst {Period}";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/statistics/hurst/Hurst.Quantower.cs";
|
||||
|
||||
public HurstIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
Name = "Hurst - Hurst Exponent";
|
||||
Description = "Measures long-range dependence using Rescaled Range (R/S) analysis. H > 0.5 = trending, H < 0.5 = mean-reverting, H ≈ 0.5 = random walk";
|
||||
|
||||
_series = new LineSeries(name: "Hurst", color: IndicatorExtensions.Statistics, width: 2, style: LineStyle.Solid);
|
||||
_halfLine = new LineSeries(name: "0.5", color: Color.Gray, width: 1, style: LineStyle.Dash);
|
||||
AddLineSeries(_series);
|
||||
AddLineSeries(_halfLine);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnInit()
|
||||
{
|
||||
_hurst = new Hurst(Period);
|
||||
_priceSelector = Source.GetPriceSelector();
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
var item = this.HistoricalData[this.Count - 1, SeekOriginHistory.Begin];
|
||||
double value = _priceSelector(item);
|
||||
var time = this.HistoricalData.Time();
|
||||
|
||||
var input = new TValue(time, value);
|
||||
TValue result = _hurst.Update(input, args.IsNewBar());
|
||||
|
||||
_series.SetValue(result.Value, _hurst.IsHot, ShowColdValues);
|
||||
_halfLine.SetValue(0.5);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// A) Constructor Validation
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
public class HurstConstructorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_ThrowsOnPeriodLessThan20()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Hurst(19));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Hurst(10));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Hurst(0));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Hurst(-1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_AcceptsMinimumPeriod()
|
||||
{
|
||||
var h = new Hurst(20);
|
||||
Assert.NotNull(h);
|
||||
Assert.Equal("Hurst(20)", h.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_SetsWarmupPeriod()
|
||||
{
|
||||
var h = new Hurst(100);
|
||||
Assert.Equal(101, h.WarmupPeriod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ParamName_IsPeriod()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => new Hurst(5));
|
||||
Assert.Equal("period", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_LargePeriodAccepted()
|
||||
{
|
||||
var h = new Hurst(500);
|
||||
Assert.Equal("Hurst(500)", h.Name);
|
||||
Assert.Equal(501, h.WarmupPeriod);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// B) Basic Calculation
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
public class HurstBasicTests
|
||||
{
|
||||
[Fact]
|
||||
public void Calc_ReturnsValue()
|
||||
{
|
||||
var h = new Hurst(20);
|
||||
TValue result = h.Update(new TValue(DateTime.UtcNow, 100));
|
||||
Assert.Equal(result.Value, h.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_FirstValue_ReturnsDefaultHalf()
|
||||
{
|
||||
var h = new Hurst(20);
|
||||
TValue result = h.Update(new TValue(DateTime.UtcNow, 100));
|
||||
Assert.Equal(0.5, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_OutputIsFinite()
|
||||
{
|
||||
var h = new Hurst(20);
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
var result = h.Update(new TValue(bar.Time, bar.Close));
|
||||
Assert.True(double.IsFinite(result.Value), $"Result at index {i} is not finite: {result.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calc_GBM_ResultNearHalf()
|
||||
{
|
||||
// GBM with zero drift should produce H ≈ 0.5 (random walk)
|
||||
var h = new Hurst(100);
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.0, sigma: 0.2, seed: 42);
|
||||
|
||||
TValue lastResult = default;
|
||||
for (int i = 0; i < 500; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
lastResult = h.Update(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
// H should be roughly 0.5 for random walk — allow wide tolerance
|
||||
Assert.InRange(lastResult.Value, 0.2, 0.8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsHot_Accessible()
|
||||
{
|
||||
var h = new Hurst(20);
|
||||
Assert.False(h.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_IsAccessible()
|
||||
{
|
||||
var h = new Hurst(50);
|
||||
Assert.Equal("Hurst(50)", h.Name);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// C) State + Bar Correction
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
public class HurstStateCorrectionTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsNew_True_Advances()
|
||||
{
|
||||
var h = new Hurst(20);
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
|
||||
for (int i = 0; i < 25; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
h.Update(new TValue(bar.Time, bar.Close), isNew: true);
|
||||
}
|
||||
double v1 = h.Last.Value;
|
||||
|
||||
var nextBar = gbm.Next(isNew: true);
|
||||
h.Update(new TValue(nextBar.Time, nextBar.Close), isNew: true);
|
||||
double v2 = h.Last.Value;
|
||||
|
||||
// Values should differ after advancing
|
||||
Assert.True(double.IsFinite(v1));
|
||||
Assert.True(double.IsFinite(v2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNew_False_Rewrites()
|
||||
{
|
||||
var h = new Hurst(20);
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
|
||||
// Build up state
|
||||
for (int i = 0; i < 25; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
h.Update(new TValue(bar.Time, bar.Close), isNew: true);
|
||||
}
|
||||
|
||||
_ = h.Last.Value;
|
||||
|
||||
// Rewrite last value
|
||||
h.Update(new TValue(DateTime.UtcNow, 200), isNew: false);
|
||||
double valueAfterRewrite = h.Last.Value;
|
||||
|
||||
// Rewrite again with original-like value
|
||||
h.Update(new TValue(DateTime.UtcNow, 200), isNew: false);
|
||||
double valueSecondRewrite = h.Last.Value;
|
||||
|
||||
// Same rewrite value should produce same result
|
||||
Assert.Equal(valueAfterRewrite, valueSecondRewrite, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreToOriginalState()
|
||||
{
|
||||
var h = new Hurst(20);
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
|
||||
|
||||
// Feed 25 new values
|
||||
TValue lastInput = default;
|
||||
for (int i = 0; i < 25; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
lastInput = new TValue(bar.Time, bar.Close);
|
||||
h.Update(lastInput, isNew: true);
|
||||
}
|
||||
|
||||
double stateAfter25 = h.Last.Value;
|
||||
|
||||
// Generate corrections with isNew=false using same last price
|
||||
h.Update(new TValue(DateTime.UtcNow, lastInput.Value + 10), isNew: false);
|
||||
h.Update(new TValue(DateTime.UtcNow, lastInput.Value + 20), isNew: false);
|
||||
|
||||
// Restore original value
|
||||
TValue finalResult = h.Update(lastInput, isNew: false);
|
||||
|
||||
Assert.Equal(stateAfter25, finalResult.Value, 10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var h = new Hurst(20);
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
h.Update(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
Assert.True(h.IsHot);
|
||||
|
||||
h.Reset();
|
||||
Assert.False(h.IsHot);
|
||||
Assert.Equal(0, h.Last.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// D) Warmup / Convergence
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
public class HurstWarmupTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsHot_BecomesTrueWhenBufferFull()
|
||||
{
|
||||
var h = new Hurst(20);
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
|
||||
// Need period+1 = 21 prices to get 20 log returns
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
h.Update(new TValue(bar.Time, bar.Close));
|
||||
Assert.False(h.IsHot, $"Should not be hot at input {i + 1}");
|
||||
}
|
||||
|
||||
// 21st price → 20th log return → buffer full
|
||||
var finalBar = gbm.Next(isNew: true);
|
||||
h.Update(new TValue(finalBar.Time, finalBar.Close));
|
||||
Assert.True(h.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmupPeriod_MatchesPeriodPlusOne()
|
||||
{
|
||||
var h = new Hurst(50);
|
||||
Assert.Equal(51, h.WarmupPeriod);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// E) Robustness
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
public class HurstRobustnessTests
|
||||
{
|
||||
[Fact]
|
||||
public void NaN_UsesLastValidValue()
|
||||
{
|
||||
var h = new Hurst(20);
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
|
||||
for (int i = 0; i < 25; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
h.Update(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
var result = h.Update(new TValue(DateTime.UtcNow, double.NaN));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PositiveInfinity_UsesLastValidValue()
|
||||
{
|
||||
var h = new Hurst(20);
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
|
||||
for (int i = 0; i < 25; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
h.Update(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
var result = h.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeInfinity_UsesLastValidValue()
|
||||
{
|
||||
var h = new Hurst(20);
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
|
||||
for (int i = 0; i < 25; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
h.Update(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
var result = h.Update(new TValue(DateTime.UtcNow, double.NegativeInfinity));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchNaN_Safe()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
double[] source = new double[50];
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
source[i] = gbm.Next(isNew: true).Close;
|
||||
}
|
||||
source[10] = double.NaN;
|
||||
source[25] = double.NaN;
|
||||
|
||||
double[] output = new double[source.Length];
|
||||
Hurst.Batch(source.AsSpan(), output.AsSpan(), 20);
|
||||
|
||||
for (int i = 0; i < output.Length; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(output[i]), $"output[{i}] = {output[i]}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// F) Consistency (all modes match)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
public class HurstConsistencyTests
|
||||
{
|
||||
[Fact]
|
||||
public void AllModes_ProduceSameResult()
|
||||
{
|
||||
const int period = 20;
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 123);
|
||||
int count = 100;
|
||||
|
||||
var times = new List<long>(count);
|
||||
var values = new List<double>(count);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
times.Add(bar.Time);
|
||||
values.Add(bar.Close);
|
||||
}
|
||||
|
||||
var series = new TSeries(times, values);
|
||||
|
||||
// 1. Batch Mode (static method)
|
||||
var batchSeries = Hurst.Batch(series, period);
|
||||
double expected = batchSeries.Last.Value;
|
||||
|
||||
// 2. Span Mode (static method with spans)
|
||||
var spanInput = values.ToArray();
|
||||
var spanOutput = new double[count];
|
||||
Hurst.Batch(spanInput.AsSpan(), spanOutput.AsSpan(), period);
|
||||
double spanResult = spanOutput[^1];
|
||||
|
||||
// 3. Streaming Mode (instance, one value at a time)
|
||||
var streamingInd = new Hurst(period);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
streamingInd.Update(series[i]);
|
||||
}
|
||||
double streamingResult = streamingInd.Last.Value;
|
||||
|
||||
// Assert all modes produce identical results
|
||||
Assert.Equal(expected, spanResult, precision: 9);
|
||||
Assert.Equal(expected, streamingResult, precision: 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_Matches_Streaming()
|
||||
{
|
||||
const int period = 20;
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
int count = 60;
|
||||
|
||||
var times = new List<long>(count);
|
||||
var values = new List<double>(count);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
times.Add(bar.Time);
|
||||
values.Add(bar.Close);
|
||||
}
|
||||
|
||||
// Streaming
|
||||
var h = new Hurst(period);
|
||||
var streamingResults = new List<double>();
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
streamingResults.Add(h.Update(new TValue(times[i], values[i])).Value);
|
||||
}
|
||||
|
||||
// Batch
|
||||
var series = new TSeries(times, values);
|
||||
var batchResult = Hurst.Batch(series, period);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Assert.Equal(streamingResults[i], batchResult.Values[i], precision: 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// G) Span API Tests
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
public class HurstSpanTests
|
||||
{
|
||||
[Fact]
|
||||
public void SpanBatch_ValidatesLengths()
|
||||
{
|
||||
double[] source = new double[50];
|
||||
double[] wrongSize = new double[30];
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Hurst.Batch(source.AsSpan(), wrongSize.AsSpan(), 20));
|
||||
Assert.Equal("output", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_ValidatesPeriod()
|
||||
{
|
||||
double[] source = new double[50];
|
||||
double[] output = new double[50];
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
Hurst.Batch(source.AsSpan(), output.AsSpan(), 19));
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
Hurst.Batch(source.AsSpan(), output.AsSpan(), 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_MatchesTSeriesBatch()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
|
||||
int count = 100;
|
||||
|
||||
var times = new List<long>(count);
|
||||
var values = new List<double>(count);
|
||||
double[] source = new double[count];
|
||||
double[] output = new double[count];
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
times.Add(bar.Time);
|
||||
values.Add(bar.Close);
|
||||
source[i] = bar.Close;
|
||||
}
|
||||
|
||||
var series = new TSeries(times, values);
|
||||
|
||||
var tseriesResult = Hurst.Batch(series, 20);
|
||||
Hurst.Batch(source.AsSpan(), output.AsSpan(), 20);
|
||||
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
Assert.Equal(tseriesResult[i].Value, output[i], 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_HandlesNaN()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
|
||||
double[] source = new double[50];
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
source[i] = gbm.Next(isNew: true).Close;
|
||||
}
|
||||
source[5] = double.NaN;
|
||||
source[15] = double.NaN;
|
||||
|
||||
double[] output = new double[50];
|
||||
Hurst.Batch(source.AsSpan(), output.AsSpan(), 20);
|
||||
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(output[i]));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_LargeData_NoStackOverflow()
|
||||
{
|
||||
int count = 5000;
|
||||
var data = new double[count];
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
data[i] = gbm.Next(isNew: true).Close;
|
||||
}
|
||||
|
||||
var output = new double[count];
|
||||
|
||||
// Should not throw StackOverflowException
|
||||
Hurst.Batch(data.AsSpan(), output.AsSpan(), 100);
|
||||
|
||||
Assert.True(double.IsFinite(output[^1]));
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// H) Event / Chainability
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
public class HurstEventTests
|
||||
{
|
||||
[Fact]
|
||||
public void Pub_Fires()
|
||||
{
|
||||
var h = new Hurst(20);
|
||||
int eventCount = 0;
|
||||
h.Pub += (object? sender, in TValueEventArgs args) => eventCount++;
|
||||
|
||||
h.Update(new TValue(DateTime.UtcNow, 100));
|
||||
|
||||
Assert.Equal(1, eventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventBased_Chaining_Works()
|
||||
{
|
||||
var source = new TSeries();
|
||||
var h = new Hurst(20);
|
||||
|
||||
source.Pub += (object? sender, in TValueEventArgs args) =>
|
||||
{
|
||||
h.Update(args.Value);
|
||||
};
|
||||
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
source.Add(new TValue(bar.Time, bar.Close));
|
||||
}
|
||||
|
||||
Assert.True(h.IsHot);
|
||||
Assert.True(double.IsFinite(h.Last.Value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// HURST Validation Tests - Hurst Exponent via Rescaled Range (R/S) Analysis
|
||||
// Validated against self-consistency and known mathematical properties
|
||||
// No external library provides a direct R/S-based Hurst exponent equivalent
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class HurstValidationTests
|
||||
{
|
||||
private static TSeries CreateGbmSeries(int count = 500, double mu = 0.0, double sigma = 0.2, int seed = 42)
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: mu, sigma: sigma, seed: seed);
|
||||
var times = new List<long>(count);
|
||||
var values = new List<double>(count);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
times.Add(bar.Time);
|
||||
values.Add(bar.Close);
|
||||
}
|
||||
return new TSeries(times, values);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A pure random walk (GBM with zero drift) should produce H near 0.5.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RandomWalk_HurstNearHalf()
|
||||
{
|
||||
const int period = 100;
|
||||
var series = CreateGbmSeries(count: 1000, mu: 0.0, sigma: 0.2, seed: 42);
|
||||
|
||||
var h = new Hurst(period);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
h.Update(series[i]);
|
||||
}
|
||||
|
||||
// H should be approximately 0.5 for random walk — allow generous tolerance
|
||||
Assert.InRange(h.Last.Value, 0.25, 0.75);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Multiple independent random walks should all produce H near 0.5.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MultipleRandomWalks_AllNearHalf()
|
||||
{
|
||||
const int period = 100;
|
||||
int[] seeds = [42, 123, 456, 789, 1024];
|
||||
|
||||
foreach (int seed in seeds)
|
||||
{
|
||||
var series = CreateGbmSeries(count: 500, mu: 0.0, sigma: 0.2, seed: seed);
|
||||
|
||||
var h = new Hurst(period);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
h.Update(series[i]);
|
||||
}
|
||||
|
||||
Assert.InRange(h.Last.Value, 0.2, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hurst exponent range — should always produce finite values within theoretically meaningful bounds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void HurstRange_AlwaysFinite()
|
||||
{
|
||||
const int period = 50;
|
||||
var series = CreateGbmSeries(count: 300, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
var h = new Hurst(period);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
var result = h.Update(series[i]);
|
||||
Assert.True(double.IsFinite(result.Value), $"Value at {i} is not finite: {result.Value}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch and streaming must produce identical results.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BatchVsStreaming_ExactMatch()
|
||||
{
|
||||
const int period = 20;
|
||||
var series = CreateGbmSeries(count: 200, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
|
||||
// Batch
|
||||
var batchResult = Hurst.Batch(series, period);
|
||||
|
||||
// Streaming
|
||||
var streamingInd = new Hurst(period);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
streamingInd.Update(series[i]);
|
||||
}
|
||||
|
||||
Assert.Equal(batchResult.Last.Value, streamingInd.Last.Value, 1e-12);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Span batch must match TSeries batch exactly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SpanBatch_MatchesTSeriesBatch()
|
||||
{
|
||||
const int period = 30;
|
||||
var series = CreateGbmSeries(count: 200, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
|
||||
var tseriesResult = Hurst.Batch(series, period);
|
||||
|
||||
double[] source = new double[series.Count];
|
||||
double[] output = new double[series.Count];
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
source[i] = series[i].Value;
|
||||
}
|
||||
|
||||
Hurst.Batch(source.AsSpan(), output.AsSpan(), period);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
Assert.Equal(tseriesResult[i].Value, output[i], 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constant price series should produce H = 0.5 (degenerate — all log returns = 0).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConstantSeries_ReturnsDefaultHalf()
|
||||
{
|
||||
const int period = 20;
|
||||
var h = new Hurst(period);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
h.Update(new TValue(DateTime.UtcNow, 100.0));
|
||||
}
|
||||
|
||||
// All log returns are zero → stddev = 0 → no valid R/S → default 0.5
|
||||
Assert.Equal(0.5, h.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate static method returns both results and indicator.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Calculate_ReturnsResultsAndIndicator()
|
||||
{
|
||||
var series = CreateGbmSeries(count: 100, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
var (results, indicator) = Hurst.Calculate(series, 20);
|
||||
|
||||
Assert.Equal(series.Count, results.Count);
|
||||
Assert.True(indicator.IsHot);
|
||||
Assert.Equal(results.Last.Value, indicator.Last.Value, 1e-12);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic: same input always produces identical output.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Deterministic_SameInputSameOutput()
|
||||
{
|
||||
const int period = 30;
|
||||
var series = CreateGbmSeries(count: 200, mu: 0.05, sigma: 0.2, seed: 42);
|
||||
|
||||
var h1 = new Hurst(period);
|
||||
var h2 = new Hurst(period);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
h1.Update(series[i]);
|
||||
h2.Update(series[i]);
|
||||
}
|
||||
|
||||
Assert.Equal(h1.Last.Value, h2.Last.Value, 1e-15);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// Hurst: Hurst Exponent via Rescaled Range (R/S) analysis.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Estimates the Hurst exponent H from a sliding window of log returns using
|
||||
/// the classical R/S method with OLS log-log regression. H measures long-range
|
||||
/// dependence: H > 0.5 indicates persistence (trending), H < 0.5 indicates
|
||||
/// anti-persistence (mean-reverting), and H ≈ 0.5 indicates a random walk.
|
||||
///
|
||||
/// Algorithm: for each sub-period size n in [10, period/2], divide the window
|
||||
/// into floor(period/n) non-overlapping blocks, compute the rescaled range R/S
|
||||
/// for each block, average, then regress log(R/S) on log(n). The slope is H.
|
||||
///
|
||||
/// Complexity: O(period²) per update — sub-period iteration is unavoidable.
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Hurst : AbstractBase
|
||||
{
|
||||
private const int MinSubPeriod = 10;
|
||||
private readonly int _period;
|
||||
private readonly RingBuffer _buffer;
|
||||
private double _prevPrice;
|
||||
private double _prevPriceSaved;
|
||||
private double _lastValidValue;
|
||||
private bool _hasPrevPrice;
|
||||
private bool _hasPrevPriceSaved;
|
||||
private int _inputCount;
|
||||
private int _inputCountSaved;
|
||||
|
||||
public override bool IsHot => _buffer.IsFull;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new Hurst Exponent indicator.
|
||||
/// </summary>
|
||||
/// <param name="period">The lookback period for log returns (must be >= 20).</param>
|
||||
public Hurst(int period)
|
||||
{
|
||||
if (period < 20)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(period), "Period must be greater than or equal to 20 for Hurst Exponent.");
|
||||
}
|
||||
_period = period;
|
||||
_buffer = new RingBuffer(period);
|
||||
Name = $"Hurst({period})";
|
||||
WarmupPeriod = period + 1;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public override TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
double value = input.Value;
|
||||
|
||||
if (!double.IsFinite(value))
|
||||
{
|
||||
value = _lastValidValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
_lastValidValue = value;
|
||||
}
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_prevPriceSaved = _prevPrice;
|
||||
_hasPrevPriceSaved = _hasPrevPrice;
|
||||
_inputCountSaved = _inputCount;
|
||||
}
|
||||
else
|
||||
{
|
||||
_prevPrice = _prevPriceSaved;
|
||||
_hasPrevPrice = _hasPrevPriceSaved;
|
||||
_inputCount = _inputCountSaved;
|
||||
}
|
||||
|
||||
double result;
|
||||
if (!_hasPrevPrice)
|
||||
{
|
||||
_prevPrice = value;
|
||||
_hasPrevPrice = true;
|
||||
_inputCount = 1;
|
||||
result = 0.5; // default — random walk assumption before data
|
||||
}
|
||||
else
|
||||
{
|
||||
double logReturn = (_prevPrice > 0 && value > 0)
|
||||
? Math.Log(value / _prevPrice)
|
||||
: 0.0;
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_buffer.Add(logReturn);
|
||||
}
|
||||
else
|
||||
{
|
||||
_buffer.UpdateNewest(logReturn);
|
||||
}
|
||||
|
||||
_prevPrice = value;
|
||||
_inputCount++;
|
||||
|
||||
result = ComputeHurst(_buffer.GetSpan());
|
||||
}
|
||||
|
||||
Last = new TValue(input.Time, result);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Batch(source.Values, vSpan, _period);
|
||||
source.Times.CopyTo(tSpan);
|
||||
|
||||
// Reset running state before priming
|
||||
_buffer.Clear();
|
||||
_prevPrice = 0;
|
||||
_hasPrevPrice = false;
|
||||
_lastValidValue = 0;
|
||||
_inputCount = 0;
|
||||
|
||||
// Prime the state: replay enough bars to reconstruct internal state
|
||||
int primeStart = Math.Max(0, len - _period - 1);
|
||||
for (int i = primeStart; i < len; i++)
|
||||
{
|
||||
Update(source[i]);
|
||||
}
|
||||
|
||||
return new TSeries(t, v);
|
||||
}
|
||||
|
||||
public override void Reset()
|
||||
{
|
||||
_buffer.Clear();
|
||||
_prevPrice = 0;
|
||||
_prevPriceSaved = 0;
|
||||
_hasPrevPrice = false;
|
||||
_hasPrevPriceSaved = false;
|
||||
_lastValidValue = 0;
|
||||
_inputCount = 0;
|
||||
_inputCountSaved = 0;
|
||||
Last = default;
|
||||
}
|
||||
|
||||
public override void Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
|
||||
{
|
||||
DateTime ts = DateTime.MinValue;
|
||||
foreach (double value in source)
|
||||
{
|
||||
Update(new TValue(ts, value));
|
||||
if (step.HasValue)
|
||||
{
|
||||
ts = ts.Add(step.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static TSeries Batch(TSeries source, int period)
|
||||
{
|
||||
var hurst = new Hurst(period);
|
||||
return hurst.Update(source);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Batch(ReadOnlySpan<double> source, Span<double> output, int period)
|
||||
{
|
||||
if (source.Length != output.Length)
|
||||
{
|
||||
throw new ArgumentException("Source and output must have the same length", nameof(output));
|
||||
}
|
||||
|
||||
if (period < 20)
|
||||
{
|
||||
throw new ArgumentException("Period must be greater than or equal to 20", nameof(period));
|
||||
}
|
||||
|
||||
int len = source.Length;
|
||||
if (len == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CalculateScalarCore(source, output, period);
|
||||
}
|
||||
|
||||
public static (TSeries Results, Hurst Indicator) Calculate(TSeries source, int period)
|
||||
{
|
||||
var indicator = new Hurst(period);
|
||||
TSeries results = indicator.Update(source);
|
||||
return (results, indicator);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void CalculateScalarCore(ReadOnlySpan<double> source, Span<double> output, int period)
|
||||
{
|
||||
int len = source.Length;
|
||||
const int StackallocThreshold = 256;
|
||||
|
||||
// Buffer for log returns within the window
|
||||
double[]? rentedLr = null;
|
||||
scoped Span<double> lrBuf;
|
||||
if (period <= StackallocThreshold)
|
||||
{
|
||||
lrBuf = stackalloc double[period];
|
||||
}
|
||||
else
|
||||
{
|
||||
rentedLr = ArrayPool<double>.Shared.Rent(period);
|
||||
lrBuf = rentedLr.AsSpan(0, period);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
if (i == 0)
|
||||
{
|
||||
output[i] = 0.5; // No log return possible yet
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compute log returns for the available window
|
||||
int windowStart = Math.Max(1, i - period + 1);
|
||||
int windowLen = i - windowStart + 1;
|
||||
|
||||
double prevValid = 0;
|
||||
for (int j = 0; j < windowLen; j++)
|
||||
{
|
||||
int srcIdx = windowStart + j;
|
||||
double cur = source[srcIdx];
|
||||
double prev = source[srcIdx - 1];
|
||||
|
||||
if (!double.IsFinite(cur)) { cur = prevValid; }
|
||||
else { prevValid = cur; }
|
||||
|
||||
double prevP = prev;
|
||||
if (!double.IsFinite(prevP)) { prevP = prevValid; }
|
||||
|
||||
lrBuf[j] = (prevP > 0 && cur > 0) ? Math.Log(cur / prevP) : 0.0;
|
||||
}
|
||||
|
||||
output[i] = ComputeHurst(lrBuf[..windowLen]);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (rentedLr is not null)
|
||||
{
|
||||
ArrayPool<double>.Shared.Return(rentedLr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the Hurst exponent from a span of log returns using R/S analysis.
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static double ComputeHurst(ReadOnlySpan<double> logReturns)
|
||||
{
|
||||
int length = logReturns.Length;
|
||||
int maxN = length / 2;
|
||||
|
||||
if (maxN < MinSubPeriod)
|
||||
{
|
||||
return 0.5; // Not enough data for R/S analysis
|
||||
}
|
||||
|
||||
// Collect log(n) and log(R/S) pairs for OLS regression
|
||||
// Max possible pairs: maxN - MinSubPeriod + 1
|
||||
int maxPairs = maxN - MinSubPeriod + 1;
|
||||
|
||||
const int StackallocThreshold = 256;
|
||||
double[]? rentedLogN = null;
|
||||
double[]? rentedLogRS = null;
|
||||
scoped Span<double> logNValues;
|
||||
scoped Span<double> logRSValues;
|
||||
|
||||
if (maxPairs <= StackallocThreshold)
|
||||
{
|
||||
logNValues = stackalloc double[maxPairs];
|
||||
logRSValues = stackalloc double[maxPairs];
|
||||
}
|
||||
else
|
||||
{
|
||||
rentedLogN = ArrayPool<double>.Shared.Rent(maxPairs);
|
||||
rentedLogRS = ArrayPool<double>.Shared.Rent(maxPairs);
|
||||
logNValues = rentedLogN.AsSpan(0, maxPairs);
|
||||
logRSValues = rentedLogRS.AsSpan(0, maxPairs);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
int pairCount = 0;
|
||||
|
||||
for (int n = MinSubPeriod; n <= maxN; n++)
|
||||
{
|
||||
int numSubPeriods = length / n;
|
||||
if (numSubPeriods == 0) { continue; }
|
||||
|
||||
double rsSum = 0.0;
|
||||
int validSubPeriods = 0;
|
||||
|
||||
for (int sp = 0; sp < numSubPeriods; sp++)
|
||||
{
|
||||
int startIndex = sp * n;
|
||||
|
||||
// Compute mean of sub-period
|
||||
double subSum = 0.0;
|
||||
for (int j = 0; j < n; j++)
|
||||
{
|
||||
subSum += logReturns[startIndex + j];
|
||||
}
|
||||
double subMean = subSum / n;
|
||||
|
||||
// Compute cumulative deviations and std dev
|
||||
double currentSum = 0.0;
|
||||
double varianceSum = 0.0;
|
||||
double cumMin = double.MaxValue;
|
||||
double cumMax = double.MinValue;
|
||||
|
||||
for (int j = 0; j < n; j++)
|
||||
{
|
||||
double deviation = logReturns[startIndex + j] - subMean;
|
||||
currentSum += deviation;
|
||||
varianceSum += deviation * deviation;
|
||||
|
||||
if (currentSum < cumMin) { cumMin = currentSum; }
|
||||
if (currentSum > cumMax) { cumMax = currentSum; }
|
||||
}
|
||||
|
||||
double rangeVal = cumMax - cumMin;
|
||||
double stdDev = Math.Sqrt(varianceSum / n);
|
||||
|
||||
if (stdDev > 1e-15)
|
||||
{
|
||||
rsSum += rangeVal / stdDev;
|
||||
validSubPeriods++;
|
||||
}
|
||||
}
|
||||
|
||||
if (validSubPeriods > 0)
|
||||
{
|
||||
double avgRS = rsSum / validSubPeriods;
|
||||
if (avgRS > 0)
|
||||
{
|
||||
logNValues[pairCount] = Math.Log(n);
|
||||
logRSValues[pairCount] = Math.Log(avgRS);
|
||||
pairCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pairCount < 2)
|
||||
{
|
||||
return 0.5; // Insufficient data points for regression
|
||||
}
|
||||
|
||||
// OLS linear regression: slope of log(R/S) vs log(n)
|
||||
return OlsSlope(logNValues[..pairCount], logRSValues[..pairCount]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (rentedLogN is not null) { ArrayPool<double>.Shared.Return(rentedLogN); }
|
||||
if (rentedLogRS is not null) { ArrayPool<double>.Shared.Return(rentedLogRS); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the OLS slope: β = (m·Σxy - Σx·Σy) / (m·Σx² - (Σx)²)
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static double OlsSlope(ReadOnlySpan<double> x, ReadOnlySpan<double> y)
|
||||
{
|
||||
int m = x.Length;
|
||||
double sumX = 0, sumY = 0, sumXY = 0, sumXSq = 0;
|
||||
|
||||
for (int i = 0; i < m; i++)
|
||||
{
|
||||
double xi = x[i];
|
||||
double yi = y[i];
|
||||
sumX += xi;
|
||||
sumY += yi;
|
||||
sumXY += xi * yi;
|
||||
sumXSq += xi * xi;
|
||||
}
|
||||
|
||||
double denominator = Math.FusedMultiplyAdd(m, sumXSq, -(sumX * sumX));
|
||||
|
||||
if (Math.Abs(denominator) < 1e-15)
|
||||
{
|
||||
return 0.5; // Degenerate — return random walk
|
||||
}
|
||||
|
||||
return Math.FusedMultiplyAdd(m, sumXY, -(sumX * sumY)) / denominator;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
# HURST: Hurst Exponent
|
||||
|
||||
> "The past is not dead. In fact, it's not even past." — William Faulkner, and also every mean-reverting time series that refuses to forget.
|
||||
|
||||
## Introduction
|
||||
|
||||
The Hurst Exponent ($H$) quantifies long-range dependence in a time series through Rescaled Range (R/S) analysis. Where autocorrelation decays and dies, the Hurst exponent measures the memory that persists across scales. $H > 0.5$ signals persistence (trending behavior), $H < 0.5$ signals anti-persistence (mean-reversion), and $H = 0.5$ represents the memoryless random walk that efficient market theorists insist you should believe in.
|
||||
|
||||
This implementation uses the classical R/S method with ordinary least squares regression on log-log coordinates, matching the PineScript reference. Period must be at least 20 to provide meaningful sub-period decomposition.
|
||||
|
||||
## Historical Context
|
||||
|
||||
Harold Edwin Hurst spent decades measuring Nile River flood levels before publishing his seminal 1951 paper. He discovered that natural phenomena exhibit stronger clustering than Brownian motion predicts. Benoit Mandelbrot later formalized this as "fractional Brownian motion" and connected it to self-similar processes.
|
||||
|
||||
The R/S statistic was the original method. Later alternatives (DFA, wavelet-based estimators, variance-ratio) address known biases in R/S for short series, but R/S remains the most widely implemented in trading platforms due to its intuitive decomposition and computational transparency.
|
||||
|
||||
The key insight: for a fractal process, $E[R/S] \propto n^H$, so regressing $\ln(R/S)$ against $\ln(n)$ yields $H$ as the slope. A pure random walk gives $H = 0.5$ (the "expected" value under no memory), while real markets routinely produce values between 0.55 and 0.75 on daily data, suggesting persistent trends are not noise.
|
||||
|
||||
## Architecture and Physics
|
||||
|
||||
### 1. Log Return Computation
|
||||
|
||||
Raw prices are converted to log returns:
|
||||
|
||||
$$r_t = \ln\left(\frac{P_t}{P_{t-1}}\right)$$
|
||||
|
||||
Log returns are additive, stationary in mean, and scale-independent. The Hurst analysis operates on a sliding window of $L$ consecutive log returns.
|
||||
|
||||
### 2. Sub-Period Decomposition
|
||||
|
||||
For each sub-period size $n$ where $10 \leq n \leq \lfloor L/2 \rfloor$:
|
||||
|
||||
- Divide the $L$ log returns into $\lfloor L/n \rfloor$ non-overlapping blocks
|
||||
- Each block contains exactly $n$ consecutive log returns
|
||||
|
||||
### 3. Rescaled Range for Each Block
|
||||
|
||||
For a block of $n$ log returns $\{r_1, r_2, \ldots, r_n\}$:
|
||||
|
||||
**Mean:**
|
||||
|
||||
$$\bar{r} = \frac{1}{n}\sum_{i=1}^{n} r_i$$
|
||||
|
||||
**Cumulative deviations:**
|
||||
|
||||
$$Y_k = \sum_{i=1}^{k}(r_i - \bar{r}), \quad k = 1, \ldots, n$$
|
||||
|
||||
**Range:**
|
||||
|
||||
$$R = \max(Y_1, \ldots, Y_n) - \min(Y_1, \ldots, Y_n)$$
|
||||
|
||||
**Standard deviation (population):**
|
||||
|
||||
$$S = \sqrt{\frac{1}{n}\sum_{i=1}^{n}(r_i - \bar{r})^2}$$
|
||||
|
||||
**Rescaled range:**
|
||||
|
||||
$$\frac{R}{S} = \frac{R}{S}, \quad S > 0$$
|
||||
|
||||
### 4. OLS Log-Log Regression
|
||||
|
||||
Average $R/S$ across all blocks for each $n$, then regress:
|
||||
|
||||
$$\ln\left(\overline{R/S}\right) = H \cdot \ln(n) + c$$
|
||||
|
||||
The slope $H$ is computed via ordinary least squares:
|
||||
|
||||
$$H = \frac{m\sum x_i y_i - \sum x_i \sum y_i}{m\sum x_i^2 - (\sum x_i)^2}$$
|
||||
|
||||
where $x_i = \ln(n_i)$, $y_i = \ln(\overline{R/S}_i)$, and $m$ is the number of valid $(n, R/S)$ pairs.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
### Hurst's Law
|
||||
|
||||
For a self-similar process with Hurst parameter $H$:
|
||||
|
||||
$$E\left[\frac{R(n)}{S(n)}\right] = c \cdot n^H$$
|
||||
|
||||
where $c$ is a constant depending on the distribution. Taking logarithms:
|
||||
|
||||
$$\ln E[R/S] = H \ln n + \ln c$$
|
||||
|
||||
### Interpretation
|
||||
|
||||
| Range | Interpretation | Market Behavior |
|
||||
|-------|---------------|-----------------|
|
||||
| $H = 0.5$ | Random walk | No memory, efficient market |
|
||||
| $0.5 < H < 1.0$ | Persistent | Trends tend to continue |
|
||||
| $0.0 < H < 0.5$ | Anti-persistent | Reversals more likely |
|
||||
| $H = 1.0$ | Perfect persistence | Deterministic trend |
|
||||
| $H = 0.0$ | Perfect anti-persistence | Deterministic oscillation |
|
||||
|
||||
### Parameter Mapping
|
||||
|
||||
| Parameter | Pine | C# | Default | Constraint |
|
||||
|-----------|------|----|---------|------------|
|
||||
| Lookback | `length` | `period` | 100 | $\geq 20$ |
|
||||
| Min sub-period | `min_n` | `MinSubPeriod` | 10 | Fixed |
|
||||
| Max sub-period | `max_n` | `period / 2` | 50 | Derived |
|
||||
|
||||
## Performance Profile
|
||||
|
||||
| Operation | Complexity | Notes |
|
||||
|-----------|-----------|-------|
|
||||
| Log return computation | $O(1)$ per bar | Single division + `Math.Log` |
|
||||
| R/S per sub-period size $n$ | $O(L)$ | Iterates blocks of size $n$ |
|
||||
| All sub-period sizes | $O(L \cdot (L/2 - 10))$ | $\approx O(L^2)$ |
|
||||
| OLS regression | $O(m)$ | $m \leq L/2 - 9$ pairs |
|
||||
| **Total per Update** | **$O(L^2)$** | Dominated by sub-period sweep |
|
||||
|
||||
### SIMD Analysis
|
||||
|
||||
The inner loops (mean, cumulative deviation, variance) operate on variable-length sub-periods making SIMD vectorization impractical for the streaming path. The Batch path uses `stackalloc` for small buffers and `ArrayPool` for large ones.
|
||||
|
||||
### Quality Metrics
|
||||
|
||||
| Metric | Score (1-10) | Notes |
|
||||
|--------|-------------|-------|
|
||||
| Accuracy | 7 | R/S has known bias for short series; adequate for $L \geq 100$ |
|
||||
| Responsiveness | 5 | Inherently lagging (needs full window rebuild) |
|
||||
| Smoothness | 6 | Stable once window fills; sensitive to outliers in small windows |
|
||||
| Computational cost | 4 | $O(L^2)$ is expensive; consider reducing period for real-time |
|
||||
|
||||
## Validation
|
||||
|
||||
No external library provides a direct R/S-based Hurst exponent for cross-validation. Validation relies on known mathematical properties:
|
||||
|
||||
| Test | Expected | Tolerance |
|
||||
|------|----------|-----------|
|
||||
| Constant series | $H = 0.5$ (degenerate) | Exact |
|
||||
| Random walk (GBM, $\mu = 0$) | $H \approx 0.5$ | $\pm 0.25$ |
|
||||
| Deterministic (same input) | Identical output | $10^{-15}$ |
|
||||
| Batch vs streaming | Identical | $10^{-12}$ |
|
||||
| Span vs TSeries | Identical | $10^{-10}$ |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Period too short**: With $L < 40$, the range of sub-period sizes ($10$ to $L/2$) is too narrow for reliable regression. Use $L \geq 100$ for production. Impact: $\pm 0.15$ bias.
|
||||
|
||||
2. **Confusing trending with persistence**: A series can have $H > 0.5$ even during drawdowns. The Hurst exponent measures autocorrelation structure, not direction. Misinterpretation leads to false directional signals.
|
||||
|
||||
3. **Non-stationarity**: The R/S method assumes locally stationary returns. Regime changes (volatility shifts, structural breaks) invalidate the power-law assumption. Impact: $H$ estimates become unreliable across breakpoints.
|
||||
|
||||
4. **R/S bias for finite samples**: Anis and Lloyd (1976) showed that $E[R/S]$ for i.i.d. normal data is not exactly $n^{0.5}$ but involves Gamma function corrections. For $n < 20$, this bias can shift $H$ by $0.05$-$0.15$.
|
||||
|
||||
5. **Zero-variance blocks**: If all returns in a sub-period are identical (e.g., during market halts), $S = 0$ and $R/S$ is undefined. These blocks are excluded from the average, reducing the effective sample size.
|
||||
|
||||
6. **Computational cost**: $O(L^2)$ per bar is expensive. For real-time streaming with $L = 500$, each update processes $\sim 125{,}000$ operations. Consider caching or reducing the update frequency. A period of 100 costs $\sim 4{,}000$ operations per update.
|
||||
|
||||
7. **Overfitting the exponent**: Point estimates of $H$ without confidence intervals invite overconfidence. The OLS slope has estimation error that grows as $m$ (number of log-log points) shrinks.
|
||||
|
||||
## References
|
||||
|
||||
- Hurst, H.E. (1951). "Long-term storage capacity of reservoirs." *Transactions of the American Society of Civil Engineers*, 116, 770-808.
|
||||
- Mandelbrot, B.B. and Wallis, J.R. (1969). "Robustness of the rescaled range R/S in the measurement of noncyclic long run statistical dependence." *Water Resources Research*, 5(5), 967-988.
|
||||
- Peters, E.E. (1994). *Fractal Market Analysis*. Wiley.
|
||||
- Anis, A.A. and Lloyd, E.H. (1976). "The expected value of the adjusted rescaled Hurst range of independent normal summands." *Biometrika*, 63(1), 111-116.
|
||||
- Lo, A.W. (1991). "Long-term memory in stock market prices." *Econometrica*, 59(5), 1279-1313.
|
||||
Reference in New Issue
Block a user