feat: add ADF (Augmented Dickey-Fuller) indicator

- Core implementation with Cholesky OLS, MacKinnon p-value, AIC lag selection
- Three regression models: NoConstant, Constant, ConstantAndTrend
- NormCdf via Abramowitz & Stegun 7.1.26 erf approximation
- Quantower adapter, Python bridge (NativeAOT export + ctypes + wrapper)
- 69 tests (41 unit + 12 validation + 14 Quantower + 2 consistency)
- Documentation with Schwert table, MacKinnon coefficients, PineScript ref
- All 19,095 tests pass, zero warnings
This commit is contained in:
Miha Kralj
2026-03-15 17:56:54 -07:00
parent 0468283d45
commit e3bd07aa87
17 changed files with 2790 additions and 2 deletions
@@ -0,0 +1,236 @@
using TradingPlatform.BusinessLayer;
namespace QuanTAlib.Tests;
public sealed class AdfIndicatorTests
{
[Fact]
public void AdfIndicator_Constructor_SetsDefaults()
{
var indicator = new AdfIndicator();
Assert.Equal(50, indicator.Period);
Assert.Equal(0, indicator.MaxLag);
Assert.Equal(1, indicator.RegressionModel); // Constant
Assert.Equal(SourceType.Close, indicator.Source);
Assert.True(indicator.ShowColdValues);
Assert.Contains("ADF", indicator.Name, StringComparison.Ordinal);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void AdfIndicator_MinHistoryDepths_EqualsPeriod()
{
var indicator = new AdfIndicator { Period = 30 };
Assert.Equal(30, indicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(30, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void AdfIndicator_ShortName_IncludesParameters()
{
var indicator = new AdfIndicator { Period = 50, MaxLag = 2, RegressionModel = 1 };
Assert.Contains("ADF", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("50", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void AdfIndicator_ShortName_ShowsRegressionModel()
{
var nc = new AdfIndicator { RegressionModel = 0 };
Assert.Contains("nc", nc.ShortName, StringComparison.Ordinal);
var c = new AdfIndicator { RegressionModel = 1 };
Assert.Contains(",c)", c.ShortName, StringComparison.Ordinal);
var ct = new AdfIndicator { RegressionModel = 2 };
Assert.Contains("ct", ct.ShortName, StringComparison.Ordinal);
}
[Fact]
public void AdfIndicator_SourceCodeLink_IsValid()
{
var indicator = new AdfIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Adf.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void AdfIndicator_Initialize_CreatesLineSeries()
{
var indicator = new AdfIndicator { Period = 30 };
indicator.Initialize();
Assert.Single(indicator.LinesSeries);
}
[Fact]
public void AdfIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new AdfIndicator { Period = 20 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
Assert.Equal(1, indicator.LinesSeries[0].Count);
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
}
[Fact]
public void AdfIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new AdfIndicator { Period = 20 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.HistoricalData.AddBar(now.AddMinutes(1), 102, 108, 100, 106);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
Assert.Equal(2, indicator.LinesSeries[0].Count);
}
[Fact]
public void AdfIndicator_ProcessUpdate_NewTick_ProcessesWithoutError()
{
var indicator = new AdfIndicator { Period = 20 };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
double firstValue = indicator.LinesSeries[0].GetValue(0);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
double secondValue = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(firstValue));
Assert.True(double.IsFinite(secondValue));
}
[Fact]
public void AdfIndicator_MultipleUpdates_ProducesCorrectSequence()
{
var indicator = new AdfIndicator { Period = 20 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 6; i++)
{
indicator.HistoricalData.AddBar(
now.AddMinutes(i), 100 + i, 105 + i, 95 + i, 102 + i);
var reason = i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar;
indicator.ProcessUpdate(new UpdateArgs(reason));
}
Assert.Equal(6, indicator.LinesSeries[0].Count);
for (int i = 0; i < 6; i++)
{
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(i)));
}
}
[Fact]
public void AdfIndicator_DifferentSourceTypes_Work()
{
var sourceTypes = new[] { SourceType.Close, SourceType.Open, SourceType.High,
SourceType.Low, SourceType.HL2, SourceType.HLC3 };
foreach (var sourceType in sourceTypes)
{
var indicator = new AdfIndicator { Period = 20, Source = sourceType };
indicator.Initialize();
var now = DateTime.UtcNow;
indicator.HistoricalData.AddBar(now, 100, 105, 95, 102);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)),
$"Failed for SourceType={sourceType}");
}
}
[Fact]
public void AdfIndicator_ShowColdValues_CanBeToggled()
{
var indicator = new AdfIndicator();
Assert.True(indicator.ShowColdValues);
indicator.ShowColdValues = false;
Assert.False(indicator.ShowColdValues);
}
[Fact]
public void AdfIndicator_OutputInRange()
{
var indicator = new AdfIndicator { Period = 20 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
indicator.HistoricalData.AddBar(
now.AddMinutes(i), 100 + i * 0.5, 105 + i * 0.5, 95 + i * 0.5, 102 + i * 0.5);
var reason = i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar;
indicator.ProcessUpdate(new UpdateArgs(reason));
}
for (int i = 0; i < 30; i++)
{
double val = indicator.LinesSeries[0].GetValue(i);
Assert.InRange(val, 0.0, 1.0);
}
}
[Fact]
public void AdfIndicator_Description_IsSet()
{
var indicator = new AdfIndicator();
Assert.False(string.IsNullOrEmpty(indicator.Description));
}
[Fact]
public void AdfIndicator_DifferentPeriods_ProduceDifferentResults()
{
var indicator30 = new AdfIndicator { Period = 20 };
var indicator50 = new AdfIndicator { Period = 30 };
indicator30.Initialize();
indicator50.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 40; i++)
{
indicator30.HistoricalData.AddBar(
now.AddMinutes(i), 100 + i, 105 + i, 95 + i, 102 + i);
indicator50.HistoricalData.AddBar(
now.AddMinutes(i), 100 + i, 105 + i, 95 + i, 102 + i);
var reason = i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar;
indicator30.ProcessUpdate(new UpdateArgs(reason));
indicator50.ProcessUpdate(new UpdateArgs(reason));
}
// After enough data, different periods should produce different results
int lastIdx = 39;
double val30 = indicator30.LinesSeries[0].GetValue(lastIdx);
double val50 = indicator50.LinesSeries[0].GetValue(lastIdx);
Assert.True(double.IsFinite(val30));
Assert.True(double.IsFinite(val50));
}
}
+517
View File
@@ -0,0 +1,517 @@
namespace QuanTAlib.Tests;
// ═══════════════════════════════════════════════════════════════
// A) Constructor Validation
// ═══════════════════════════════════════════════════════════════
public class AdfConstructorTests
{
[Fact]
public void Constructor_ThrowsOnPeriodLessThan20()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new Adf(19));
Assert.Throws<ArgumentOutOfRangeException>(() => new Adf(10));
Assert.Throws<ArgumentOutOfRangeException>(() => new Adf(0));
Assert.Throws<ArgumentOutOfRangeException>(() => new Adf(-1));
}
[Fact]
public void Constructor_AcceptsMinimumPeriod()
{
var a = new Adf(20);
Assert.NotNull(a);
Assert.Contains("ADF", a.Name, StringComparison.Ordinal);
Assert.Contains("20", a.Name, StringComparison.Ordinal);
}
[Fact]
public void Constructor_SetsWarmupPeriod()
{
var a = new Adf(100);
Assert.Equal(100, a.WarmupPeriod);
}
[Fact]
public void Constructor_ThrowsOnNegativeMaxLag()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new Adf(50, -1));
}
[Fact]
public void Constructor_AcceptsZeroMaxLag()
{
var a = new Adf(50, 0);
Assert.NotNull(a);
}
[Fact]
public void Constructor_AcceptsExplicitMaxLag()
{
var a = new Adf(50, 3);
Assert.Contains("3", a.Name, StringComparison.Ordinal);
}
[Fact]
public void Constructor_DefaultRegression_IsConstant()
{
var a = new Adf(50);
Assert.Contains("c", a.Name, StringComparison.Ordinal);
}
[Fact]
public void Constructor_AllRegressionModels()
{
var nc = new Adf(50, 0, Adf.AdfRegression.NoConstant);
Assert.Contains("nc", nc.Name, StringComparison.Ordinal);
var c = new Adf(50, 0, Adf.AdfRegression.Constant);
Assert.Contains(",c)", c.Name, StringComparison.Ordinal);
var ct = new Adf(50, 0, Adf.AdfRegression.ConstantAndTrend);
Assert.Contains("ct", ct.Name, StringComparison.Ordinal);
}
[Fact]
public void Constructor_LargePeriod()
{
var a = new Adf(500);
Assert.Equal("ADF(500,0,c)", a.Name);
Assert.Equal(500, a.WarmupPeriod);
}
[Fact]
public void Constructor_ParamName_IsPeriod()
{
var ex = Assert.Throws<ArgumentOutOfRangeException>(() => new Adf(5));
Assert.Equal("period", ex.ParamName);
}
}
// ═══════════════════════════════════════════════════════════════
// B) Basic Calculation
// ═══════════════════════════════════════════════════════════════
public class AdfBasicTests
{
[Fact]
public void Calc_ReturnsValue()
{
var a = new Adf(20);
TValue result = a.Update(new TValue(DateTime.UtcNow, 100));
Assert.Equal(result.Value, a.Last.Value);
}
[Fact]
public void Calc_FirstValue_ReturnsOne()
{
var a = new Adf(20);
TValue result = a.Update(new TValue(DateTime.UtcNow, 100));
Assert.Equal(1.0, result.Value); // Not enough data → p=1.0
}
[Fact]
public void Calc_OutputIsFinite()
{
var a = new Adf(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 = a.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_OutputInRange_ZeroToOne()
{
var a = new Adf(30);
var gbm = new GBM(startPrice: 100, mu: 0.0, sigma: 0.3, seed: 123);
for (int i = 0; i < 200; i++)
{
var bar = gbm.Next(isNew: true);
var result = a.Update(new TValue(bar.Time, bar.Close));
Assert.InRange(result.Value, 0.0, 1.0);
}
}
[Fact]
public void Calc_PValueProperty_MatchesOutput()
{
var a = new Adf(30);
var gbm = new GBM(startPrice: 100, mu: 0.0, sigma: 0.2, seed: 42);
for (int i = 0; i < 50; i++)
{
var bar = gbm.Next(isNew: true);
var result = a.Update(new TValue(bar.Time, bar.Close));
Assert.Equal(result.Value, a.PValue);
}
}
[Fact]
public void Calc_StatisticProperty_IsFinite()
{
var a = new Adf(30);
var gbm = new GBM(startPrice: 100, mu: 0.0, sigma: 0.2, seed: 42);
for (int i = 0; i < 50; i++)
{
var bar = gbm.Next(isNew: true);
a.Update(new TValue(bar.Time, bar.Close));
}
Assert.True(double.IsFinite(a.Statistic));
}
[Fact]
public void Calc_LagsUsedProperty_IsNonNegative()
{
var a = new Adf(50);
var gbm = new GBM(startPrice: 100, mu: 0.0, sigma: 0.2, seed: 42);
for (int i = 0; i < 60; i++)
{
var bar = gbm.Next(isNew: true);
a.Update(new TValue(bar.Time, bar.Close));
}
Assert.True(a.LagsUsed >= 0);
}
}
// ═══════════════════════════════════════════════════════════════
// C) State & Bar Correction
// ═══════════════════════════════════════════════════════════════
public class AdfStateTests
{
[Fact]
public void BarCorrection_IsNewFalse_DoesNotCrash()
{
var a = new Adf(20);
var now = DateTime.UtcNow;
a.Update(new TValue(now, 100), isNew: true);
a.Update(new TValue(now, 101), isNew: false);
a.Update(new TValue(now, 102), isNew: false);
Assert.True(double.IsFinite(a.Last.Value));
}
[Fact]
public void Reset_ClearsState()
{
var a = new Adf(20);
var gbm = new GBM(startPrice: 100, mu: 0.0, sigma: 0.2, seed: 42);
for (int i = 0; i < 30; i++)
{
var bar = gbm.Next(isNew: true);
a.Update(new TValue(bar.Time, bar.Close));
}
Assert.NotEqual(default, a.Last);
a.Reset();
Assert.Equal(default, a.Last);
Assert.Equal(1.0, a.PValue);
Assert.Equal(0, a.LagsUsed);
Assert.False(a.IsHot);
}
[Fact]
public void IsHot_BecomesTrue_AfterWarmup()
{
var a = new Adf(20);
var gbm = new GBM(startPrice: 100, mu: 0.0, sigma: 0.2, seed: 42);
for (int i = 0; i < 19; i++)
{
var bar = gbm.Next(isNew: true);
a.Update(new TValue(bar.Time, bar.Close));
Assert.False(a.IsHot);
}
var lastBar = gbm.Next(isNew: true);
a.Update(new TValue(lastBar.Time, lastBar.Close));
// After period bars, should be or getting close to hot
// IsHot requires _inputCount > _period
lastBar = gbm.Next(isNew: true);
a.Update(new TValue(lastBar.Time, lastBar.Close));
Assert.True(a.IsHot);
}
}
// ═══════════════════════════════════════════════════════════════
// D) Robustness
// ═══════════════════════════════════════════════════════════════
public class AdfRobustnessTests
{
[Fact]
public void NaN_InputIsHandled()
{
var a = new Adf(20);
a.Update(new TValue(DateTime.UtcNow, 100));
a.Update(new TValue(DateTime.UtcNow.AddMinutes(1), double.NaN));
a.Update(new TValue(DateTime.UtcNow.AddMinutes(2), 102));
Assert.True(double.IsFinite(a.Last.Value));
}
[Fact]
public void Infinity_InputIsHandled()
{
var a = new Adf(20);
a.Update(new TValue(DateTime.UtcNow, 100));
a.Update(new TValue(DateTime.UtcNow.AddMinutes(1), double.PositiveInfinity));
a.Update(new TValue(DateTime.UtcNow.AddMinutes(2), 102));
Assert.True(double.IsFinite(a.Last.Value));
}
[Fact]
public void ConstantInput_ReturnsUnitRoot()
{
var a = new Adf(25);
var now = DateTime.UtcNow;
for (int i = 0; i < 30; i++)
{
a.Update(new TValue(now.AddMinutes(i), 100.0));
}
// Constant input has no variation → should return high p-value or handle gracefully
Assert.True(double.IsFinite(a.PValue));
Assert.InRange(a.PValue, 0.0, 1.0);
}
}
// ═══════════════════════════════════════════════════════════════
// E) Consistency
// ═══════════════════════════════════════════════════════════════
public class AdfConsistencyTests
{
[Fact]
public void BatchTSeries_MatchesStreaming()
{
int period = 30;
var gbm = new GBM(startPrice: 100, mu: 0.0, sigma: 0.2, seed: 42);
var source = new TSeries();
for (int i = 0; i < 100; i++)
{
var bar = gbm.Next(isNew: true);
source.Add(new TValue(bar.Time, bar.Close));
}
// Batch
var batchResult = Adf.Batch(source, period);
// Streaming
var streaming = new Adf(period);
var streamResults = new List<double>();
for (int i = 0; i < source.Count; i++)
{
var result = streaming.Update(source[i]);
streamResults.Add(result.Value);
}
// Final values should be close (not exact due to floating-point paths)
Assert.Equal(batchResult.Count, streamResults.Count);
for (int i = 0; i < batchResult.Count; i++)
{
Assert.True(double.IsFinite(streamResults[i]));
Assert.InRange(streamResults[i], 0.0, 1.0);
}
}
[Fact]
public void BatchSpan_OutputMatchesTSeries()
{
int period = 30;
var gbm = new GBM(startPrice: 100, mu: 0.0, sigma: 0.2, seed: 42);
var source = new TSeries();
for (int i = 0; i < 80; i++)
{
var bar = gbm.Next(isNew: true);
source.Add(new TValue(bar.Time, bar.Close));
}
_ = Adf.Batch(source, period);
double[] spanOutput = new double[source.Count];
Adf.Batch(source.Values, spanOutput.AsSpan(), period);
for (int i = 0; i < source.Count; i++)
{
Assert.InRange(spanOutput[i], 0.0, 1.0);
}
}
[Fact]
public void Calculate_ReturnsResultsAndIndicator()
{
var gbm = new GBM(startPrice: 100, mu: 0.0, sigma: 0.2, seed: 42);
var source = new TSeries();
for (int i = 0; i < 60; i++)
{
var bar = gbm.Next(isNew: true);
source.Add(new TValue(bar.Time, bar.Close));
}
var (results, indicator) = Adf.Calculate(source, 30);
Assert.NotNull(results);
Assert.NotNull(indicator);
Assert.Equal(source.Count, results.Count);
Assert.True(indicator.IsHot);
}
[Fact]
public void Prime_SetsState()
{
var a = new Adf(25);
double[] data = new double[30];
var rng = new Random(42);
double price = 100;
for (int i = 0; i < 30; i++)
{
price += rng.NextDouble() * 2 - 1;
data[i] = price;
}
a.Prime(data);
Assert.True(a.IsHot);
Assert.True(double.IsFinite(a.PValue));
}
}
// ═══════════════════════════════════════════════════════════════
// F) ADF-Specific Tests
// ═══════════════════════════════════════════════════════════════
public class AdfSpecificTests
{
[Fact]
public void StationarySeries_LowPValue()
{
// Create a mean-reverting series: y_t = 0.5 * y_{t-1} + noise
var a = new Adf(50, 1, Adf.AdfRegression.Constant);
var rng = new Random(42);
double y = 100;
var now = DateTime.UtcNow;
for (int i = 0; i < 200; i++)
{
y = 100 + 0.5 * (y - 100) + rng.NextDouble() * 2 - 1;
a.Update(new TValue(now.AddMinutes(i), y));
}
// A strongly mean-reverting series should have p-value well below 0.05
Assert.True(a.PValue < 0.10, $"Expected p < 0.10 for stationary series, got {a.PValue}");
}
[Fact]
public void RandomWalk_HighPValue()
{
// Create a pure random walk: y_t = y_{t-1} + noise
var a = new Adf(50, 1, Adf.AdfRegression.Constant);
var rng = new Random(123);
double y = 100;
var now = DateTime.UtcNow;
for (int i = 0; i < 200; i++)
{
y += rng.NextDouble() * 2 - 1;
a.Update(new TValue(now.AddMinutes(i), y));
}
// A random walk should typically have p > 0.05
Assert.True(a.PValue > 0.05, $"Expected p > 0.05 for random walk, got {a.PValue}");
}
[Fact]
public void DifferentRegressions_ProduceDifferentPValues()
{
var rng = new Random(42);
double y = 100;
var source = new TSeries();
for (int i = 0; i < 80; i++)
{
y += rng.NextDouble() * 2 - 1;
source.Add(new TValue(DateTime.UtcNow.AddMinutes(i), y));
}
var ncResult = Adf.Batch(source, 50, 1, Adf.AdfRegression.NoConstant);
var cResult = Adf.Batch(source, 50, 1, Adf.AdfRegression.Constant);
var ctResult = Adf.Batch(source, 50, 1, Adf.AdfRegression.ConstantAndTrend);
// All should be valid
int last = source.Count - 1;
Assert.InRange(ncResult.Values[last], 0.0, 1.0);
Assert.InRange(cResult.Values[last], 0.0, 1.0);
Assert.InRange(ctResult.Values[last], 0.0, 1.0);
// At least two should differ (very unlikely all three are identical)
Assert.False(
ncResult.Values[last] == cResult.Values[last] &&
cResult.Values[last] == ctResult.Values[last],
"All three regression models produced identical p-values — unexpected");
}
[Fact]
public void ExplicitLag_DiffersFromAutoLag()
{
var rng = new Random(42);
double y = 100;
var source = new TSeries();
for (int i = 0; i < 100; i++)
{
y += rng.NextDouble() * 2 - 1;
source.Add(new TValue(DateTime.UtcNow.AddMinutes(i), y));
}
var (autoResult, _) = Adf.Calculate(source, 50, 0);
var (explicitResult, _) = Adf.Calculate(source, 50, 3);
// Auto and explicit lag should produce different results (usually)
int last = source.Count - 1;
Assert.InRange(autoResult.Values[last], 0.0, 1.0);
Assert.InRange(explicitResult.Values[last], 0.0, 1.0);
}
[Fact]
public void DifferentPeriods_ProduceDifferentResults()
{
var rng = new Random(42);
double y = 100;
var source = new TSeries();
for (int i = 0; i < 200; i++)
{
y += rng.NextDouble() * 2 - 1;
source.Add(new TValue(DateTime.UtcNow.AddMinutes(i), y));
}
var result30 = Adf.Batch(source, 30);
var result100 = Adf.Batch(source, 100);
int last = source.Count - 1;
Assert.InRange(result30.Values[last], 0.0, 1.0);
Assert.InRange(result100.Values[last], 0.0, 1.0);
// Different periods should usually give different results
Assert.NotEqual(result30.Values[last], result100.Values[last]);
}
[Fact]
public void EventPub_IsFired()
{
var a = new Adf(20);
int eventCount = 0;
a.Pub += (object? sender, in TValueEventArgs args) => eventCount++;
for (int i = 0; i < 25; i++)
{
a.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100 + i));
}
Assert.Equal(25, eventCount);
}
}
@@ -0,0 +1,292 @@
namespace QuanTAlib.Tests;
/// <summary>
/// Validation tests for the ADF indicator — verifying mathematical properties
/// and cross-checking against known statistical behaviors.
/// </summary>
public class AdfValidationTests
{
// ═══════════════════════════════════════════════════════════════
// 1. P-Value Bounds
// ═══════════════════════════════════════════════════════════════
[Fact]
public void PValue_AlwaysBetweenZeroAndOne()
{
var seeds = new[] { 1, 42, 123, 999, 31415 };
foreach (int seed in seeds)
{
var a = new Adf(30);
var gbm = new GBM(startPrice: 100, mu: 0.0, sigma: 0.3, seed: seed);
for (int i = 0; i < 100; i++)
{
var bar = gbm.Next(isNew: true);
var result = a.Update(new TValue(bar.Time, bar.Close));
Assert.InRange(result.Value, 0.0, 1.0);
}
}
}
// ═══════════════════════════════════════════════════════════════
// 2. Known Stationary Process
// ═══════════════════════════════════════════════════════════════
[Fact]
public void AR1_WithStrongMeanReversion_DetectsStationarity()
{
// AR(1): y_t = 0.3 * y_{t-1} + ε_t (|φ| < 1 → stationary)
var a = new Adf(50, 1, Adf.AdfRegression.Constant);
var rng = new Random(42);
double y = 0;
var now = DateTime.UtcNow;
for (int i = 0; i < 500; i++)
{
y = 0.3 * y + rng.NextDouble() * 2 - 1;
a.Update(new TValue(now.AddMinutes(i), 100 + y));
}
// Strong mean-reversion — p should be very low
Assert.True(a.PValue < 0.05, $"AR(1) φ=0.3 should be detected as stationary, p={a.PValue}");
}
[Fact]
public void WhiteNoise_IsStationary()
{
// Pure white noise is strongly stationary — use explicit lag=1 to avoid
// auto-lag overfitting on small windows, and zero-centered noise for clean signal
var a = new Adf(50, 1, Adf.AdfRegression.Constant);
var rng = new Random(42);
var now = DateTime.UtcNow;
for (int i = 0; i < 500; i++)
{
double noise = rng.NextDouble() * 10 - 5; // zero-centered white noise
a.Update(new TValue(now.AddMinutes(i), noise));
}
Assert.True(a.PValue < 0.10, $"White noise should be stationary, p={a.PValue}");
}
// ═══════════════════════════════════════════════════════════════
// 3. Known Non-Stationary Process
// ═══════════════════════════════════════════════════════════════
[Fact]
public void PureRandomWalk_FailsToRejectUnitRoot()
{
// y_t = y_{t-1} + ε_t (unit root)
var a = new Adf(50, 1, Adf.AdfRegression.Constant);
var rng = new Random(789);
double y = 100;
var now = DateTime.UtcNow;
for (int i = 0; i < 500; i++)
{
y += rng.NextDouble() * 2 - 1;
a.Update(new TValue(now.AddMinutes(i), y));
}
Assert.True(a.PValue > 0.05, $"Random walk should not reject unit root, p={a.PValue}");
}
[Fact]
public void LinearTrend_WithNoConstantModel_AppearsNonStationary()
{
// Pure linear trend y_t = t
var a = new Adf(50, 0, Adf.AdfRegression.NoConstant);
var now = DateTime.UtcNow;
for (int i = 0; i < 100; i++)
{
a.Update(new TValue(now.AddMinutes(i), 100.0 + i * 0.1));
}
// Linear trend without constant/trend in model should appear non-stationary
Assert.InRange(a.PValue, 0.0, 1.0);
Assert.True(double.IsFinite(a.Statistic));
}
// ═══════════════════════════════════════════════════════════════
// 4. MacKinnon P-Value Properties
// ═══════════════════════════════════════════════════════════════
[Fact]
public void VeryNegativeStatistic_GivesLowPValue()
{
// Feed data that will produce very negative t-stat (strongly stationary)
var a = new Adf(30, 0, Adf.AdfRegression.Constant);
var rng = new Random(42);
var now = DateTime.UtcNow;
// Oscillating series: y_t = -0.9 * y_{t-1} + noise → very negative γ
double y = 0;
for (int i = 0; i < 100; i++)
{
y = -0.9 * y + rng.NextDouble() * 0.1;
a.Update(new TValue(now.AddMinutes(i), 50 + y));
}
Assert.True(a.PValue < 0.01, $"Strong oscillation should give p < 0.01, got {a.PValue}");
}
// ═══════════════════════════════════════════════════════════════
// 5. Consistency Across API Modes
// ═══════════════════════════════════════════════════════════════
[Fact]
public void BatchAndStreaming_ProduceConsistentResults()
{
int period = 30;
var rng = new Random(42);
double y = 100;
var source = new TSeries();
for (int i = 0; i < 80; i++)
{
y += rng.NextDouble() * 2 - 1;
source.Add(new TValue(DateTime.UtcNow.AddMinutes(i), y));
}
// Batch via TSeries
var batchResult = Adf.Batch(source, period);
// Span batch
double[] spanOutput = new double[source.Count];
Adf.Batch(source.Values, spanOutput.AsSpan(), period);
// Both should be in valid range
for (int i = 0; i < source.Count; i++)
{
Assert.InRange(batchResult.Values[i], 0.0, 1.0);
Assert.InRange(spanOutput[i], 0.0, 1.0);
}
}
// ═══════════════════════════════════════════════════════════════
// 6. Determinism
// ═══════════════════════════════════════════════════════════════
[Fact]
public void SameInput_ProducesSameOutput()
{
double[] data = { 100, 101, 99, 102, 98, 103, 97, 104, 96, 105,
94, 106, 93, 107, 92, 108, 91, 109, 90, 110,
89, 111, 88, 112, 87, 113, 86, 114, 85, 115 };
var a1 = new Adf(25);
var a2 = new Adf(25);
for (int i = 0; i < data.Length; i++)
{
var tv = new TValue(DateTime.UtcNow.AddMinutes(i), data[i]);
a1.Update(tv);
a2.Update(tv);
}
Assert.Equal(a1.PValue, a2.PValue);
Assert.Equal(a1.Statistic, a2.Statistic);
Assert.Equal(a1.LagsUsed, a2.LagsUsed);
}
// ═══════════════════════════════════════════════════════════════
// 7. Reset and Reprocess
// ═══════════════════════════════════════════════════════════════
[Fact]
public void ResetAndReprocess_GivesSameResult()
{
var a = new Adf(25);
var rng = new Random(42);
double y = 100;
var data = new List<TValue>();
for (int i = 0; i < 40; i++)
{
y += rng.NextDouble() * 2 - 1;
data.Add(new TValue(DateTime.UtcNow.AddMinutes(i), y));
}
// First pass
foreach (var tv in data)
{
a.Update(tv);
}
double firstPValue = a.PValue;
double firstStat = a.Statistic;
// Reset and second pass
a.Reset();
foreach (var tv in data)
{
a.Update(tv);
}
Assert.Equal(firstPValue, a.PValue);
Assert.Equal(firstStat, a.Statistic);
}
// ═══════════════════════════════════════════════════════════════
// 8. Auto-Lag Selection
// ═══════════════════════════════════════════════════════════════
[Fact]
public void AutoLag_SelectsReasonableLag()
{
var a = new Adf(50, 0, Adf.AdfRegression.Constant);
var rng = new Random(42);
double y = 100;
var now = DateTime.UtcNow;
for (int i = 0; i < 100; i++)
{
y += rng.NextDouble() * 2 - 1;
a.Update(new TValue(now.AddMinutes(i), y));
}
// Auto-lag should select a small number of lags
Assert.True(a.LagsUsed >= 0);
Assert.True(a.LagsUsed <= 5, $"Auto-lag selected {a.LagsUsed} lags — seems excessive for 50-bar window");
}
// ═══════════════════════════════════════════════════════════════
// 9. Edge Cases
// ═══════════════════════════════════════════════════════════════
[Fact]
public void MinimumPeriod_StillWorks()
{
var a = new Adf(20, 0, Adf.AdfRegression.Constant);
var rng = new Random(42);
double y = 100;
var now = DateTime.UtcNow;
for (int i = 0; i < 25; i++)
{
y += rng.NextDouble() * 2 - 1;
a.Update(new TValue(now.AddMinutes(i), y));
}
Assert.True(double.IsFinite(a.PValue));
Assert.InRange(a.PValue, 0.0, 1.0);
}
[Fact]
public void FixedLagZero_NoAugmentation()
{
var a = new Adf(30, 1, Adf.AdfRegression.Constant);
var rng = new Random(42);
double y = 100;
var now = DateTime.UtcNow;
for (int i = 0; i < 50; i++)
{
y += rng.NextDouble() * 2 - 1;
a.Update(new TValue(now.AddMinutes(i), y));
}
// With explicit lag=1, should get finite result
Assert.True(double.IsFinite(a.PValue));
Assert.Equal(1, a.LagsUsed);
}
}