mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-01 19:27:44 +00:00
060649192f
- Remove 'C# Implementation Considerations' sections from 34 indicator .md files - Delete 29 temp PowerShell scripts (_fix_mojibake.ps1, _hex_scan.ps1, etc.) - Move test files into tests/ subdirectories for consistent project structure - Add trader-focused bullet points to indicator documentation
311 lines
9.4 KiB
C#
311 lines
9.4 KiB
C#
using Skender.Stock.Indicators;
|
|
using TALib;
|
|
using Xunit;
|
|
|
|
namespace QuanTAlib.Tests;
|
|
|
|
/// <summary>
|
|
/// Stochastic Oscillator validation tests.
|
|
/// Cross-validates against Skender.Stock.Indicators.GetStoch with smoothPeriods=1
|
|
/// (Fast Stochastic matches our raw %K), plus self-consistency checks.
|
|
/// </summary>
|
|
public sealed class StochValidationTests : IDisposable
|
|
{
|
|
private readonly ValidationTestData _data = new();
|
|
private bool _disposed;
|
|
|
|
public void Dispose()
|
|
{
|
|
Dispose(disposing: true);
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
|
|
private void Dispose(bool disposing)
|
|
{
|
|
if (!_disposed && disposing)
|
|
{
|
|
_data.Dispose();
|
|
_disposed = true;
|
|
}
|
|
}
|
|
|
|
private static TBarSeries GenerateSeries(int count, int seed = 42)
|
|
{
|
|
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: seed);
|
|
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
|
}
|
|
|
|
// --- A) Streaming vs Batch agreement ---
|
|
|
|
[Fact]
|
|
public void Streaming_Matches_Batch()
|
|
{
|
|
var series = GenerateSeries(300);
|
|
const int kLength = 14;
|
|
const int dPeriod = 3;
|
|
|
|
var stoch = new Stoch(kLength, dPeriod);
|
|
for (int i = 0; i < series.Count; i++)
|
|
{
|
|
stoch.Update(series[i]);
|
|
}
|
|
|
|
var (batchK, batchD) = Stoch.Batch(series, kLength, dPeriod);
|
|
|
|
Assert.Equal(stoch.K.Value, batchK[^1].Value, 1e-6);
|
|
Assert.Equal(stoch.D.Value, batchD[^1].Value, 1e-6);
|
|
}
|
|
|
|
// --- B) Span matches TBarSeries ---
|
|
|
|
[Fact]
|
|
public void Span_Matches_TBarSeries()
|
|
{
|
|
var series = GenerateSeries(200);
|
|
const int kLength = 14;
|
|
const int dPeriod = 3;
|
|
|
|
var (tbK, tbD) = Stoch.Batch(series, kLength, dPeriod);
|
|
|
|
var kOut = new double[series.Count];
|
|
var dOut = new double[series.Count];
|
|
Stoch.Batch(series.HighValues, series.LowValues, series.CloseValues,
|
|
kOut.AsSpan(), dOut.AsSpan(), kLength, dPeriod);
|
|
|
|
for (int i = 0; i < series.Count; i++)
|
|
{
|
|
Assert.Equal(tbK.Values[i], kOut[i], 12);
|
|
Assert.Equal(tbD.Values[i], dOut[i], 12);
|
|
}
|
|
}
|
|
|
|
// --- C) Constant bars → K=0 ---
|
|
|
|
[Fact]
|
|
public void ConstantBars_K_Is_Zero()
|
|
{
|
|
const int kLength = 14;
|
|
const int dPeriod = 3;
|
|
int count = 50;
|
|
|
|
var bars = new TBarSeries();
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
bars.Add(new TBar(DateTime.UtcNow.AddMinutes(i), 50, 50, 50, 50, 100));
|
|
}
|
|
|
|
var (kSeries, dSeries) = Stoch.Batch(bars, kLength, dPeriod);
|
|
|
|
// When range=0 for all bars, %K and %D should be 0
|
|
for (int i = kLength - 1; i < count; i++)
|
|
{
|
|
Assert.Equal(0.0, kSeries.Values[i], 1e-10);
|
|
Assert.Equal(0.0, dSeries.Values[i], 1e-10);
|
|
}
|
|
}
|
|
|
|
// --- D) Directional correctness ---
|
|
|
|
[Fact]
|
|
public void Rising_Produces_High_K()
|
|
{
|
|
const int kLength = 5;
|
|
const int dPeriod = 3;
|
|
|
|
var bars = new TBarSeries();
|
|
for (int i = 0; i < 20; i++)
|
|
{
|
|
double price = 100.0 + (i * 2.0);
|
|
bars.Add(new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 1, price - 1, price + 1, 100));
|
|
}
|
|
|
|
var stoch = new Stoch(kLength, dPeriod);
|
|
for (int i = 0; i < bars.Count; i++)
|
|
{
|
|
stoch.Update(bars[i]);
|
|
}
|
|
|
|
// Close at recent high → %K should be near 100
|
|
Assert.True(stoch.K.Value > 80.0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Falling_Produces_Low_K()
|
|
{
|
|
const int kLength = 5;
|
|
const int dPeriod = 3;
|
|
|
|
var bars = new TBarSeries();
|
|
for (int i = 0; i < 20; i++)
|
|
{
|
|
double price = 200.0 - (i * 2.0);
|
|
bars.Add(new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 1, price - 1, price - 1, 100));
|
|
}
|
|
|
|
var stoch = new Stoch(kLength, dPeriod);
|
|
for (int i = 0; i < bars.Count; i++)
|
|
{
|
|
stoch.Update(bars[i]);
|
|
}
|
|
|
|
// Close at recent low → %K should be near 0
|
|
Assert.True(stoch.K.Value < 20.0);
|
|
}
|
|
|
|
// --- E) Cross-validation with Skender ---
|
|
|
|
[Fact]
|
|
public void Skender_K_Matches_With_SmoothK1()
|
|
{
|
|
// Skender GetStoch(lookbackPeriods, signalPeriods, smoothPeriods)
|
|
// smoothPeriods=1 means no SMA smoothing on %K → raw Fast %K == our %K
|
|
const int kLength = 14;
|
|
const int dPeriod = 3;
|
|
|
|
var (qK, qD) = Stoch.Batch(_data.Bars, kLength, dPeriod);
|
|
|
|
var skResults = _data.SkenderQuotes.GetStoch(kLength, dPeriod, 1).ToList();
|
|
|
|
// Compare converged values (skip warmup)
|
|
int start = kLength + dPeriod;
|
|
int totalCompared = 0;
|
|
int mismatches = 0;
|
|
|
|
for (int i = start; i < _data.Bars.Count; i++)
|
|
{
|
|
double? skK = skResults[i].Oscillator;
|
|
double? skD = skResults[i].Signal;
|
|
|
|
if (skK.HasValue && skD.HasValue)
|
|
{
|
|
totalCompared++;
|
|
double errK = Math.Abs(qK.Values[i] - skK.Value);
|
|
double errD = Math.Abs(qD.Values[i] - skD.Value);
|
|
|
|
if (errK > 1e-6 || errD > 1e-6)
|
|
{
|
|
mismatches++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Allow small fraction of mismatches due to warmup initialization differences
|
|
Assert.True(totalCompared > 0, "No Skender results to compare");
|
|
double mismatchRate = (double)mismatches / totalCompared;
|
|
Assert.True(mismatchRate < 0.05, $"Mismatch rate {mismatchRate:P2} exceeds 5% threshold ({mismatches}/{totalCompared})");
|
|
}
|
|
|
|
// --- F) Determinism ---
|
|
|
|
[Fact]
|
|
public void Deterministic_Across_Runs()
|
|
{
|
|
var series = GenerateSeries(200, seed: 99);
|
|
const int kLength = 14;
|
|
const int dPeriod = 3;
|
|
|
|
var (k1, d1) = Stoch.Batch(series, kLength, dPeriod);
|
|
var (k2, d2) = Stoch.Batch(series, kLength, dPeriod);
|
|
|
|
for (int i = 0; i < series.Count; i++)
|
|
{
|
|
Assert.Equal(k1.Values[i], k2.Values[i], 15);
|
|
Assert.Equal(d1.Values[i], d2.Values[i], 15);
|
|
}
|
|
}
|
|
|
|
// --- G) Multi-period consistency ---
|
|
|
|
[Fact]
|
|
public void Different_Periods_Produce_Different_Results()
|
|
{
|
|
var series = GenerateSeries(100);
|
|
|
|
var (k5, _) = Stoch.Batch(series, kLength: 5, dPeriod: 3);
|
|
var (k20, _) = Stoch.Batch(series, kLength: 20, dPeriod: 3);
|
|
|
|
// Different kLength should produce different %K values after warmup
|
|
bool anyDifferent = false;
|
|
for (int i = 20; i < 100; i++)
|
|
{
|
|
if (Math.Abs(k5.Values[i] - k20.Values[i]) > 0.01)
|
|
{
|
|
anyDifferent = true;
|
|
break;
|
|
}
|
|
}
|
|
Assert.True(anyDifferent);
|
|
}
|
|
|
|
// --- H) Calculate returns both results and indicator ---
|
|
|
|
[Fact]
|
|
public void Calculate_Produces_Consistent_Results()
|
|
{
|
|
var series = GenerateSeries(100);
|
|
const int kLength = 14;
|
|
const int dPeriod = 3;
|
|
|
|
var (results, indicator) = Stoch.Calculate(series, kLength, dPeriod);
|
|
|
|
Assert.Equal(100, results.K.Count);
|
|
Assert.Equal(100, results.D.Count);
|
|
Assert.True(indicator.IsHot);
|
|
Assert.True(double.IsFinite(indicator.K.Value));
|
|
Assert.True(double.IsFinite(indicator.D.Value));
|
|
}
|
|
|
|
// --- I) TALib cross-validation ---
|
|
|
|
/// <summary>
|
|
/// TALib Stoch(fastKPeriod=14, slowKPeriod=1, slowKTALib.Core.MAType=SMA, slowDPeriod=3, slowDTALib.Core.MAType=SMA)
|
|
/// with slowKPeriod=1 (no K smoothing) produces raw %K == our K output.
|
|
/// slowD with SMA(3) matches our D output.
|
|
/// Note: TALib Stoch uses SMA for both K and D smoothing (TALib.Core.MAType=SMA).
|
|
/// QuanTAlib Stoch also uses SMA. With slowKPeriod=1 (identity) the K lines match directly.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TALib_Stoch_K_And_D_Match()
|
|
{
|
|
const int kLength = 14;
|
|
const int dPeriod = 3;
|
|
|
|
var hData = _data.HighPrices.Span;
|
|
var lData = _data.LowPrices.Span;
|
|
var cData = _data.ClosePrices.Span;
|
|
|
|
double[] taK = new double[hData.Length];
|
|
double[] taD = new double[hData.Length];
|
|
|
|
// positional: fastKPeriod=14, slowKPeriod=1 (no smoothing), SMA, slowDPeriod=3, SMA
|
|
var retCode = TALib.Functions.Stoch(hData, lData, cData, 0..^0,
|
|
taK, taD, out var outRange,
|
|
kLength, 1, TALib.Core.MAType.Sma, dPeriod, TALib.Core.MAType.Sma);
|
|
Assert.Equal(TALib.Core.RetCode.Success, retCode);
|
|
|
|
(int offset, int length) = outRange.GetOffsetAndLength(taK.Length);
|
|
|
|
var (qK, qD) = Stoch.Batch(_data.Bars, kLength, dPeriod);
|
|
|
|
int mismatches = 0;
|
|
for (int j = 0; j < length; j++)
|
|
{
|
|
int qi = j + offset;
|
|
double errK = Math.Abs(qK.Values[qi] - taK[j]);
|
|
double errD = Math.Abs(qD.Values[qi] - taD[j]);
|
|
if (errK > 1e-6 || errD > 1e-6) { mismatches++; }
|
|
}
|
|
|
|
double mismatchRate = (double)mismatches / length;
|
|
Assert.True(mismatchRate < 0.05,
|
|
$"TALib Stoch mismatch rate {mismatchRate:P2} > 5% ({mismatches}/{length})");
|
|
}
|
|
|
|
[Fact]
|
|
public void TALib_Stoch_Lookback_Is_Positive()
|
|
{
|
|
int lookback = TALib.Functions.StochLookback(14, 1, TALib.Core.MAType.Sma, 3, TALib.Core.MAType.Sma);
|
|
Assert.True(lookback > 0, $"TALib Stoch lookback={lookback}");
|
|
}
|
|
}
|