mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-21 03:58:04 +00:00
Add Stochastic Oscillator implementation and validation tests
- Implemented Stochastic Oscillator (%K and %D) in Stoch.cs with streaming and batch processing capabilities. - Added validation tests for the Stochastic Oscillator in Stoch.Validation.Tests.cs, ensuring consistency with Skender.Stock.Indicators. - Created documentation for the Stochastic Oscillator in Stoch.md, detailing its mathematical formula, architecture, parameters, and common pitfalls. - Updated project file to include necessary numeric libraries for highest and lowest calculations.
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using QuanTAlib;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class StochIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void StochIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new StochIndicator();
|
||||
|
||||
Assert.Equal(14, indicator.KLength);
|
||||
Assert.Equal(3, indicator.DPeriod);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("STOCH", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StochIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new StochIndicator { KLength = 14, DPeriod = 3 };
|
||||
|
||||
Assert.Equal(0, StochIndicator.MinHistoryDepths);
|
||||
IWatchlistIndicator watchlistIndicator = indicator;
|
||||
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StochIndicator_ShortName_IncludesParameters()
|
||||
{
|
||||
var indicator = new StochIndicator { KLength = 14, DPeriod = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Contains("STOCH", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("14", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("5", indicator.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StochIndicator_SourceCodeLink_IsValid()
|
||||
{
|
||||
var indicator = new StochIndicator();
|
||||
|
||||
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
Assert.Contains("Stoch", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StochIndicator_Initialize_CreatesInternalStoch()
|
||||
{
|
||||
var indicator = new StochIndicator { KLength = 14, DPeriod = 3 };
|
||||
|
||||
indicator.Initialize();
|
||||
|
||||
// After init, line series should exist (K, D)
|
||||
Assert.Equal(2, indicator.LinesSeries.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StochIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new StochIndicator { KLength = 5, DPeriod = 3 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; 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 k = indicator.LinesSeries[0].GetValue(0);
|
||||
double d = indicator.LinesSeries[1].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(k));
|
||||
Assert.True(double.IsFinite(d));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StochIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new StochIndicator { KLength = 5, DPeriod = 3 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
// Simulate a new bar
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(10), 110, 120, 100, 115);
|
||||
var newArgs = new UpdateArgs(UpdateReason.NewBar);
|
||||
indicator.ProcessUpdate(newArgs);
|
||||
|
||||
double k = indicator.LinesSeries[0].GetValue(0);
|
||||
double d = indicator.LinesSeries[1].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(k));
|
||||
Assert.True(double.IsFinite(d));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class StochIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("K Length", sortIndex: 1, 1, 500, 1, 0)]
|
||||
public int KLength { get; set; } = 14;
|
||||
|
||||
[InputParameter("D Period", sortIndex: 2, 1, 50, 1, 0)]
|
||||
public int DPeriod { get; set; } = 3;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Stoch _stoch = null!;
|
||||
private readonly LineSeries _kSeries;
|
||||
private readonly LineSeries _dSeries;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"STOCH {KLength},{DPeriod}";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/stoch/Stoch.cs";
|
||||
|
||||
public StochIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
Name = "STOCH";
|
||||
Description = "Stochastic Oscillator with %K and %D lines";
|
||||
|
||||
_kSeries = new LineSeries(name: "K", color: Color.Green, width: 2, style: LineStyle.Solid);
|
||||
_dSeries = new LineSeries(name: "D", color: Color.Red, width: 2, style: LineStyle.Solid);
|
||||
|
||||
AddLineSeries(_kSeries);
|
||||
AddLineSeries(_dSeries);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnInit()
|
||||
{
|
||||
_stoch = new Stoch(KLength, DPeriod);
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
_ = _stoch.Update(this.GetInputBar(args), args.IsNewBar());
|
||||
|
||||
_kSeries.SetValue(_stoch.K.Value, _stoch.IsHot, ShowColdValues);
|
||||
_dSeries.SetValue(_stoch.D.Value, _stoch.IsHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class StochTests
|
||||
{
|
||||
private static TBarSeries GenerateBars(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) Constructor validation ===
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidKLength_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Stoch(kLength: 0));
|
||||
Assert.Equal("kLength", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidDPeriod_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Stoch(kLength: 14, dPeriod: 0));
|
||||
Assert.Equal("dPeriod", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativeKLength_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Stoch(kLength: -5));
|
||||
Assert.Equal("kLength", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativeDPeriod_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Stoch(kLength: 5, dPeriod: -1));
|
||||
Assert.Equal("dPeriod", ex.ParamName);
|
||||
}
|
||||
|
||||
// === B) Basic calculation ===
|
||||
|
||||
[Fact]
|
||||
public void Update_ReturnsTValue()
|
||||
{
|
||||
var stoch = new Stoch(kLength: 5, dPeriod: 3);
|
||||
var bar = new TBar(DateTime.UtcNow, 10, 12, 8, 11, 100);
|
||||
TValue result = stoch.Update(bar);
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Last_K_D_Accessible()
|
||||
{
|
||||
var stoch = new Stoch(kLength: 5, dPeriod: 3);
|
||||
var bar = new TBar(DateTime.UtcNow, 10, 12, 8, 11, 100);
|
||||
stoch.Update(bar);
|
||||
Assert.True(double.IsFinite(stoch.Last.Value));
|
||||
Assert.True(double.IsFinite(stoch.K.Value));
|
||||
Assert.True(double.IsFinite(stoch.D.Value));
|
||||
Assert.NotEmpty(stoch.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstantBars_K_Is_Zero_Or_Defined()
|
||||
{
|
||||
var stoch = new Stoch(kLength: 5, dPeriod: 3);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 50, 50, 50, 50, 100);
|
||||
stoch.Update(bar);
|
||||
}
|
||||
// When all H=L=C, range=0, so %K=0
|
||||
Assert.Equal(0.0, stoch.K.Value);
|
||||
Assert.Equal(0.0, stoch.D.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RisingBars_K_Approaches_100()
|
||||
{
|
||||
var stoch = new Stoch(kLength: 5, dPeriod: 3);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
double price = 100.0 + i;
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 0.5, price - 0.5, price + 0.5, 100);
|
||||
stoch.Update(bar);
|
||||
}
|
||||
// Close at recent high should produce high %K
|
||||
Assert.True(stoch.K.Value > 50.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FallingBars_K_Approaches_0()
|
||||
{
|
||||
var stoch = new Stoch(kLength: 5, dPeriod: 3);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
double price = 200.0 - i;
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), price, price + 0.5, price - 0.5, price - 0.5, 100);
|
||||
stoch.Update(bar);
|
||||
}
|
||||
// Close at recent low should produce low %K
|
||||
Assert.True(stoch.K.Value < 50.0);
|
||||
}
|
||||
|
||||
// === C) State + bar correction ===
|
||||
|
||||
[Fact]
|
||||
public void IsNew_True_Advances_State()
|
||||
{
|
||||
var stoch = new Stoch(kLength: 5, dPeriod: 3);
|
||||
var bars = GenerateBars(10);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
stoch.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
_ = stoch.K.Value;
|
||||
|
||||
// Feed one more bar
|
||||
var nextBar = new TBar(DateTime.UtcNow.AddMinutes(100), 105, 110, 100, 108, 100);
|
||||
stoch.Update(nextBar, isNew: true);
|
||||
|
||||
// State should have advanced — K may differ
|
||||
Assert.True(double.IsFinite(stoch.K.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNew_False_Rewrites()
|
||||
{
|
||||
var stoch = new Stoch(kLength: 5, dPeriod: 3);
|
||||
var bars = GenerateBars(10);
|
||||
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
stoch.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
stoch.Update(bars[9], isNew: true);
|
||||
double kAfterNew = stoch.K.Value;
|
||||
|
||||
// Update same bar position with different value
|
||||
var corrected = new TBar(bars[9].Time, 999, 1005, 995, 1000, 100);
|
||||
stoch.Update(corrected, isNew: false);
|
||||
double kAfterCorrect = stoch.K.Value;
|
||||
|
||||
// Correcting with very different price should change K
|
||||
Assert.NotEqual(kAfterNew, kAfterCorrect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_Restore()
|
||||
{
|
||||
var stoch = new Stoch(kLength: 5, dPeriod: 3);
|
||||
var bars = GenerateBars(15);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
stoch.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
_ = stoch.K.Value;
|
||||
_ = stoch.D.Value;
|
||||
|
||||
// Apply correction
|
||||
stoch.Update(bars[10], isNew: true);
|
||||
// Roll back with correction
|
||||
stoch.Update(bars[10], isNew: false);
|
||||
// Apply same bar again
|
||||
stoch.Update(bars[10], isNew: false);
|
||||
|
||||
// Multiple corrections of the same bar should converge
|
||||
double kAfter = stoch.K.Value;
|
||||
Assert.True(double.IsFinite(kAfter));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var stoch = new Stoch(kLength: 5, dPeriod: 3);
|
||||
var bars = GenerateBars(20);
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
stoch.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
Assert.True(stoch.IsHot);
|
||||
stoch.Reset();
|
||||
Assert.False(stoch.IsHot);
|
||||
Assert.Equal(default, stoch.Last);
|
||||
Assert.Equal(default, stoch.K);
|
||||
Assert.Equal(default, stoch.D);
|
||||
}
|
||||
|
||||
// === D) Warmup/convergence ===
|
||||
|
||||
[Fact]
|
||||
public void IsHot_FlipsAfterKLength()
|
||||
{
|
||||
var stoch = new Stoch(kLength: 5, dPeriod: 3);
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100 + i, 102 + i, 98 + i, 101 + i, 100);
|
||||
stoch.Update(bar);
|
||||
Assert.False(stoch.IsHot);
|
||||
}
|
||||
|
||||
var bar5 = new TBar(DateTime.UtcNow.AddMinutes(4), 104, 106, 102, 105, 100);
|
||||
stoch.Update(bar5);
|
||||
Assert.True(stoch.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmupPeriod_MatchesKLength()
|
||||
{
|
||||
var stoch = new Stoch(kLength: 10, dPeriod: 3);
|
||||
Assert.Equal(10, stoch.WarmupPeriod);
|
||||
}
|
||||
|
||||
// === E) Robustness ===
|
||||
|
||||
[Fact]
|
||||
public void NaN_UsesLastValid()
|
||||
{
|
||||
var stoch = new Stoch(kLength: 5, dPeriod: 3);
|
||||
|
||||
// Feed valid bars first
|
||||
for (int i = 0; i < 6; i++)
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100 + i, 102 + i, 98 + i, 101 + i, 100);
|
||||
stoch.Update(bar);
|
||||
}
|
||||
|
||||
_ = stoch.K.Value;
|
||||
|
||||
// Feed NaN bar — should use last valid
|
||||
var nanBar = new TBar(DateTime.UtcNow.AddMinutes(10), double.NaN, double.NaN, double.NaN, double.NaN, 0);
|
||||
stoch.Update(nanBar);
|
||||
Assert.True(double.IsFinite(stoch.K.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_UsesLastValid()
|
||||
{
|
||||
var stoch = new Stoch(kLength: 5, dPeriod: 3);
|
||||
|
||||
for (int i = 0; i < 6; i++)
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100 + i, 102 + i, 98 + i, 101 + i, 100);
|
||||
stoch.Update(bar);
|
||||
}
|
||||
|
||||
var infBar = new TBar(DateTime.UtcNow.AddMinutes(10), double.PositiveInfinity, double.PositiveInfinity,
|
||||
double.NegativeInfinity, double.PositiveInfinity, 0);
|
||||
stoch.Update(infBar);
|
||||
Assert.True(double.IsFinite(stoch.K.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllNaN_ReturnsNaN()
|
||||
{
|
||||
var stoch = new Stoch(kLength: 5, dPeriod: 3);
|
||||
|
||||
// No valid data ever
|
||||
var nanBar = new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 0);
|
||||
stoch.Update(nanBar);
|
||||
Assert.True(double.IsNaN(stoch.K.Value));
|
||||
Assert.True(double.IsNaN(stoch.D.Value));
|
||||
}
|
||||
|
||||
// === F) Consistency ===
|
||||
|
||||
[Fact]
|
||||
public void StreamingMatchesBatch()
|
||||
{
|
||||
const int kLength = 14;
|
||||
const int dPeriod = 3;
|
||||
var bars = GenerateBars(100);
|
||||
|
||||
// Streaming
|
||||
var stochStream = new Stoch(kLength: kLength, dPeriod: dPeriod);
|
||||
var streamK = new double[100];
|
||||
var streamD = new double[100];
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
stochStream.Update(bars[i], isNew: true);
|
||||
streamK[i] = stochStream.K.Value;
|
||||
streamD[i] = stochStream.D.Value;
|
||||
}
|
||||
|
||||
// Batch (TBarSeries)
|
||||
var (batchK, batchD) = Stoch.Batch(bars, kLength, dPeriod);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
Assert.Equal(streamK[i], batchK.Values[i], 10);
|
||||
Assert.Equal(streamD[i], batchD.Values[i], 10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanMatchesTBarSeries()
|
||||
{
|
||||
const int kLength = 14;
|
||||
const int dPeriod = 3;
|
||||
var bars = GenerateBars(100);
|
||||
|
||||
// TBarSeries batch
|
||||
var (tbK, tbD) = Stoch.Batch(bars, kLength, dPeriod);
|
||||
|
||||
// Span batch
|
||||
var kOut = new double[100];
|
||||
var dOut = new double[100];
|
||||
Stoch.Batch(bars.HighValues, bars.LowValues, bars.CloseValues,
|
||||
kOut.AsSpan(), dOut.AsSpan(), kLength, dPeriod);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
Assert.Equal(tbK.Values[i], kOut[i], 12);
|
||||
Assert.Equal(tbD.Values[i], dOut[i], 12);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventMatchesStreaming()
|
||||
{
|
||||
const int kLength = 14;
|
||||
const int dPeriod = 3;
|
||||
var bars = GenerateBars(50);
|
||||
|
||||
var stochDirect = new Stoch(kLength: kLength, dPeriod: dPeriod);
|
||||
var directK = new double[50];
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
stochDirect.Update(bars[i], isNew: true);
|
||||
directK[i] = stochDirect.K.Value;
|
||||
}
|
||||
|
||||
// Event-based via TBarSeries subscription
|
||||
var barSeries = new TBarSeries();
|
||||
var stochEvent = new Stoch(barSeries, kLength: kLength, dPeriod: dPeriod);
|
||||
var eventK = new List<double>();
|
||||
stochEvent.Pub += (object? _, in TValueEventArgs e) => eventK.Add(e.Value.Value);
|
||||
|
||||
// Re-prime so events fire from index 0
|
||||
stochEvent.Reset();
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
barSeries.Add(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
// Event list may lag due to priming; compare from end
|
||||
Assert.True(eventK.Count >= 50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateTBarSeries_MatchesStreaming()
|
||||
{
|
||||
const int kLength = 14;
|
||||
const int dPeriod = 3;
|
||||
var bars = GenerateBars(100);
|
||||
|
||||
// Streaming
|
||||
var stochStream = new Stoch(kLength: kLength, dPeriod: dPeriod);
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
stochStream.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
// Update(TBarSeries)
|
||||
var stochBatch = new Stoch(kLength: kLength, dPeriod: dPeriod);
|
||||
var (kSeries, dSeries) = stochBatch.Update(bars);
|
||||
|
||||
Assert.Equal(stochStream.K.Value, kSeries.Values[^1], 10);
|
||||
Assert.Equal(stochStream.D.Value, dSeries.Values[^1], 10);
|
||||
}
|
||||
|
||||
// === G) Span API tests ===
|
||||
|
||||
[Fact]
|
||||
public void Batch_EmptyInput_NoException()
|
||||
{
|
||||
var kOut = Array.Empty<double>();
|
||||
var dOut = Array.Empty<double>();
|
||||
Stoch.Batch(ReadOnlySpan<double>.Empty, ReadOnlySpan<double>.Empty,
|
||||
ReadOnlySpan<double>.Empty, kOut.AsSpan(), dOut.AsSpan(), 14, 3);
|
||||
Assert.Empty(kOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_InvalidKLength_Throws()
|
||||
{
|
||||
var kOut = new double[5];
|
||||
var dOut = new double[5];
|
||||
var src = new double[] { 1, 2, 3, 4, 5 };
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Stoch.Batch(src.AsSpan(), src.AsSpan(), src.AsSpan(), kOut.AsSpan(), dOut.AsSpan(), 0, 3));
|
||||
Assert.Equal("kLength", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_InvalidDPeriod_Throws()
|
||||
{
|
||||
var kOut = new double[5];
|
||||
var dOut = new double[5];
|
||||
var src = new double[] { 1, 2, 3, 4, 5 };
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Stoch.Batch(src.AsSpan(), src.AsSpan(), src.AsSpan(), kOut.AsSpan(), dOut.AsSpan(), 5, 0));
|
||||
Assert.Equal("dPeriod", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_MismatchedInputLengths_Throws()
|
||||
{
|
||||
var high = new double[] { 1, 2, 3 };
|
||||
var low = new double[] { 1, 2 };
|
||||
var close = new double[] { 1, 2, 3 };
|
||||
var kOut = new double[3];
|
||||
var dOut = new double[3];
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
Stoch.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), kOut.AsSpan(), dOut.AsSpan(), 3, 3));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_OutputTooShort_Throws()
|
||||
{
|
||||
var src = new double[] { 1, 2, 3, 4, 5 };
|
||||
var kOut = new double[3]; // too short
|
||||
var dOut = new double[5];
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Stoch.Batch(src.AsSpan(), src.AsSpan(), src.AsSpan(), kOut.AsSpan(), dOut.AsSpan(), 3, 3));
|
||||
Assert.Equal("kOut", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_DOutputTooShort_Throws()
|
||||
{
|
||||
var src = new double[] { 1, 2, 3, 4, 5 };
|
||||
var kOut = new double[5];
|
||||
var dOut = new double[3]; // too short
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Stoch.Batch(src.AsSpan(), src.AsSpan(), src.AsSpan(), kOut.AsSpan(), dOut.AsSpan(), 3, 3));
|
||||
Assert.Equal("dOut", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_LargeData_NoStackOverflow()
|
||||
{
|
||||
int count = 1000;
|
||||
var bars = GenerateBars(count);
|
||||
var kOut = new double[count];
|
||||
var dOut = new double[count];
|
||||
|
||||
// Should not throw — uses ArrayPool for large buffers
|
||||
Stoch.Batch(bars.HighValues, bars.LowValues, bars.CloseValues,
|
||||
kOut.AsSpan(), dOut.AsSpan(), 14, 3);
|
||||
|
||||
Assert.True(double.IsFinite(kOut[^1]));
|
||||
Assert.True(double.IsFinite(dOut[^1]));
|
||||
}
|
||||
|
||||
// === H) Chainability ===
|
||||
|
||||
[Fact]
|
||||
public void Pub_FiresOnUpdate()
|
||||
{
|
||||
var stoch = new Stoch(kLength: 5, dPeriod: 3);
|
||||
int fireCount = 0;
|
||||
stoch.Pub += (object? _, in TValueEventArgs _) => fireCount++;
|
||||
|
||||
var bar = new TBar(DateTime.UtcNow, 10, 12, 8, 11, 100);
|
||||
stoch.Update(bar);
|
||||
|
||||
Assert.Equal(1, fireCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TValue_Overload_Works()
|
||||
{
|
||||
var stoch = new Stoch(kLength: 5, dPeriod: 3);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
stoch.Update(new TValue(DateTime.UtcNow.AddMinutes(i), 100.0 + i));
|
||||
}
|
||||
|
||||
// TValue creates H=L=C bars, so range = 0 once window is all same-height
|
||||
Assert.True(double.IsFinite(stoch.K.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_MatchesParameters()
|
||||
{
|
||||
var stoch = new Stoch(kLength: 14, dPeriod: 3);
|
||||
Assert.Equal("Stoch(14,3)", stoch.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ReturnsResultsAndIndicator()
|
||||
{
|
||||
var bars = GenerateBars(50);
|
||||
var (results, indicator) = Stoch.Calculate(bars, kLength: 14, dPeriod: 3);
|
||||
|
||||
Assert.Equal(50, results.K.Count);
|
||||
Assert.Equal(50, results.D.Count);
|
||||
Assert.True(indicator.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void K_Bounded_0_100()
|
||||
{
|
||||
var stoch = new Stoch(kLength: 5, dPeriod: 3);
|
||||
var bars = GenerateBars(100);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
stoch.Update(bars[i], isNew: true);
|
||||
double k = stoch.K.Value;
|
||||
if (double.IsFinite(k))
|
||||
{
|
||||
Assert.InRange(k, -0.001, 100.001);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseAtHigh_K_Is_100()
|
||||
{
|
||||
var stoch = new Stoch(kLength: 5, dPeriod: 3);
|
||||
|
||||
// Build up a range first
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100, 110, 90, 100, 100);
|
||||
stoch.Update(bar);
|
||||
}
|
||||
|
||||
// Close at the absolute highest high with range present
|
||||
var topBar = new TBar(DateTime.UtcNow.AddMinutes(4), 100, 110, 90, 110, 100);
|
||||
stoch.Update(topBar);
|
||||
|
||||
Assert.Equal(100.0, stoch.K.Value, 6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseAtLow_K_Is_0()
|
||||
{
|
||||
var stoch = new Stoch(kLength: 5, dPeriod: 3);
|
||||
|
||||
// Build up a range first
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 100, 110, 90, 100, 100);
|
||||
stoch.Update(bar);
|
||||
}
|
||||
|
||||
// Close at the absolute lowest low with range present
|
||||
var botBar = new TBar(DateTime.UtcNow.AddMinutes(4), 100, 110, 90, 90, 100);
|
||||
stoch.Update(botBar);
|
||||
|
||||
Assert.Equal(0.0, stoch.K.Value, 6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
using Skender.Stock.Indicators;
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// STOCH: Stochastic Oscillator (%K and %D).
|
||||
/// %K = 100 * (close - lowestLow) / (highestHigh - lowestLow).
|
||||
/// %D = SMA(%K, dPeriod).
|
||||
/// Streaming path uses monotonic deques for O(1) amortized highest/lowest;
|
||||
/// %D uses a circular buffer with running sum for O(1) SMA.
|
||||
/// </summary>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Stoch : ITValuePublisher
|
||||
{
|
||||
private readonly int _kLength;
|
||||
private readonly int _dPeriod;
|
||||
private readonly double[] _hBuf;
|
||||
private readonly double[] _lBuf;
|
||||
private readonly double[] _dBuf;
|
||||
private readonly MonotonicDeque _maxDeque;
|
||||
private readonly MonotonicDeque _minDeque;
|
||||
|
||||
private int _count;
|
||||
private long _index;
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(
|
||||
double DSum, int DHead, double PrevDVal,
|
||||
double LastValidHigh, double LastValidLow, double LastValidClose);
|
||||
|
||||
private State _s;
|
||||
private State _ps;
|
||||
|
||||
private readonly TBarPublishedHandler _barHandler;
|
||||
|
||||
public string Name { get; }
|
||||
public int WarmupPeriod { get; }
|
||||
public TValue Last { get; private set; }
|
||||
public TValue K { get; private set; }
|
||||
public TValue D { get; private set; }
|
||||
public bool IsHot => _count >= _kLength;
|
||||
|
||||
public event TValuePublishedHandler? Pub;
|
||||
|
||||
public Stoch(int kLength = 14, int dPeriod = 3)
|
||||
{
|
||||
if (kLength <= 0)
|
||||
{
|
||||
throw new ArgumentException("K length must be greater than 0", nameof(kLength));
|
||||
}
|
||||
if (dPeriod <= 0)
|
||||
{
|
||||
throw new ArgumentException("D period must be greater than 0", nameof(dPeriod));
|
||||
}
|
||||
|
||||
_kLength = kLength;
|
||||
_dPeriod = dPeriod;
|
||||
_hBuf = new double[_kLength];
|
||||
_lBuf = new double[_kLength];
|
||||
_dBuf = new double[_dPeriod];
|
||||
_maxDeque = new MonotonicDeque(_kLength);
|
||||
_minDeque = new MonotonicDeque(_kLength);
|
||||
_count = 0;
|
||||
_index = -1;
|
||||
_s = new State(0.0, 0, 0.0, double.NaN, double.NaN, double.NaN);
|
||||
_ps = _s;
|
||||
|
||||
Name = $"Stoch({kLength},{dPeriod})";
|
||||
WarmupPeriod = kLength;
|
||||
_barHandler = HandleBar;
|
||||
}
|
||||
|
||||
public Stoch(TBarSeries source, int kLength = 14, int dPeriod = 3) : this(kLength, dPeriod)
|
||||
{
|
||||
Prime(source);
|
||||
source.Pub += _barHandler;
|
||||
}
|
||||
|
||||
private void HandleBar(object? sender, in TBarEventArgs e) => Update(e.Value, e.IsNew);
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private void PubEvent(TValue value, bool isNew = true) =>
|
||||
Pub?.Invoke(this, new TValueEventArgs { Value = value, IsNew = isNew });
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TBar input, bool isNew = true)
|
||||
{
|
||||
if (isNew)
|
||||
{
|
||||
_ps = _s;
|
||||
_index++;
|
||||
if (_count < _kLength)
|
||||
{
|
||||
_count++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_s = _ps;
|
||||
}
|
||||
|
||||
var s = _s;
|
||||
|
||||
// Validate inputs — substitute last-valid on NaN/Infinity
|
||||
double high = input.High;
|
||||
double low = input.Low;
|
||||
double close = input.Close;
|
||||
|
||||
if (double.IsFinite(high)) { s.LastValidHigh = high; }
|
||||
else { high = s.LastValidHigh; }
|
||||
|
||||
if (double.IsFinite(low)) { s.LastValidLow = low; }
|
||||
else { low = s.LastValidLow; }
|
||||
|
||||
if (double.IsFinite(close)) { s.LastValidClose = close; }
|
||||
else { close = s.LastValidClose; }
|
||||
|
||||
// If still no valid data, return NaN
|
||||
if (double.IsNaN(high) || double.IsNaN(low) || double.IsNaN(close))
|
||||
{
|
||||
_s = s;
|
||||
Last = new TValue(input.Time, double.NaN);
|
||||
K = new TValue(input.Time, double.NaN);
|
||||
D = new TValue(input.Time, double.NaN);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
int bufIdx = _index < 0 ? 0 : (int)(_index % _kLength);
|
||||
_hBuf[bufIdx] = high;
|
||||
_lBuf[bufIdx] = low;
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
_maxDeque.PushMax(_index, high, _hBuf);
|
||||
_minDeque.PushMin(_index, low, _lBuf);
|
||||
}
|
||||
else
|
||||
{
|
||||
_maxDeque.RebuildMax(_hBuf, _index, _count);
|
||||
_minDeque.RebuildMin(_lBuf, _index, _count);
|
||||
}
|
||||
|
||||
double highest = _maxDeque.GetExtremum(_hBuf);
|
||||
double lowest = _minDeque.GetExtremum(_lBuf);
|
||||
double range = highest - lowest;
|
||||
|
||||
double kVal = range > 0.0 ? 100.0 * (close - lowest) / range : 0.0;
|
||||
|
||||
// SMA of %K for %D using circular buffer + running sum
|
||||
if (_index == 0)
|
||||
{
|
||||
// First bar: fill entire buffer with kVal
|
||||
for (int i = 0; i < _dPeriod; i++)
|
||||
{
|
||||
_dBuf[i] = kVal;
|
||||
}
|
||||
s.DSum = kVal * _dPeriod;
|
||||
s.DHead = 0;
|
||||
s.PrevDVal = kVal;
|
||||
}
|
||||
else
|
||||
{
|
||||
int dIdx = s.DHead;
|
||||
s.PrevDVal = _dBuf[dIdx];
|
||||
s.DSum = s.DSum - s.PrevDVal + kVal;
|
||||
_dBuf[dIdx] = kVal;
|
||||
if (isNew)
|
||||
{
|
||||
s.DHead = (dIdx + 1) % _dPeriod;
|
||||
}
|
||||
}
|
||||
|
||||
double dVal = s.DSum / _dPeriod;
|
||||
|
||||
_s = s;
|
||||
|
||||
K = new TValue(input.Time, kVal);
|
||||
D = new TValue(input.Time, dVal);
|
||||
Last = new TValue(input.Time, kVal);
|
||||
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TValue input, bool isNew = true) =>
|
||||
Update(new TBar(input.Time, input.Value, input.Value, input.Value, input.Value, 0), isNew);
|
||||
|
||||
public (TSeries K, TSeries D) Update(TBarSeries source)
|
||||
{
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return (new TSeries([], []), new TSeries([], []));
|
||||
}
|
||||
|
||||
int len = source.Count;
|
||||
var tK = new List<long>(len);
|
||||
var vK = new List<double>(len);
|
||||
var tD = new List<long>(len);
|
||||
var vD = new List<double>(len);
|
||||
|
||||
CollectionsMarshal.SetCount(tK, len);
|
||||
CollectionsMarshal.SetCount(vK, len);
|
||||
CollectionsMarshal.SetCount(tD, len);
|
||||
CollectionsMarshal.SetCount(vD, len);
|
||||
|
||||
var vKSpan = CollectionsMarshal.AsSpan(vK);
|
||||
var vDSpan = CollectionsMarshal.AsSpan(vD);
|
||||
|
||||
Batch(source.HighValues, source.LowValues, source.CloseValues,
|
||||
vKSpan, vDSpan, _kLength, _dPeriod);
|
||||
|
||||
var tSpan = CollectionsMarshal.AsSpan(tK);
|
||||
source.Times.CopyTo(tSpan);
|
||||
tSpan.CopyTo(CollectionsMarshal.AsSpan(tD));
|
||||
|
||||
// Prime internal state for continued streaming
|
||||
Prime(source);
|
||||
|
||||
var lastTime = new DateTime(source.Times[^1], DateTimeKind.Utc);
|
||||
K = new TValue(lastTime, vKSpan[^1]);
|
||||
D = new TValue(lastTime, vDSpan[^1]);
|
||||
Last = new TValue(lastTime, vKSpan[^1]);
|
||||
|
||||
return (new TSeries(tK, vK), new TSeries(tD, vD));
|
||||
}
|
||||
|
||||
public void Prime(TBarSeries source)
|
||||
{
|
||||
Reset();
|
||||
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Update(source[i], isNew: true);
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
Array.Clear(_hBuf);
|
||||
Array.Clear(_lBuf);
|
||||
Array.Clear(_dBuf);
|
||||
_maxDeque.Reset();
|
||||
_minDeque.Reset();
|
||||
_count = 0;
|
||||
_index = -1;
|
||||
_s = new State(0.0, 0, 0.0, double.NaN, double.NaN, double.NaN);
|
||||
_ps = _s;
|
||||
Last = default;
|
||||
K = default;
|
||||
D = default;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Batch(
|
||||
ReadOnlySpan<double> high,
|
||||
ReadOnlySpan<double> low,
|
||||
ReadOnlySpan<double> close,
|
||||
Span<double> kOut,
|
||||
Span<double> dOut,
|
||||
int kLength,
|
||||
int dPeriod = 3)
|
||||
{
|
||||
if (kLength <= 0)
|
||||
{
|
||||
throw new ArgumentException("K length must be greater than 0", nameof(kLength));
|
||||
}
|
||||
if (dPeriod <= 0)
|
||||
{
|
||||
throw new ArgumentException("D period must be greater than 0", nameof(dPeriod));
|
||||
}
|
||||
if (high.Length != low.Length || high.Length != close.Length)
|
||||
{
|
||||
throw new ArgumentException("Input spans must have the same length", nameof(high));
|
||||
}
|
||||
if (kOut.Length < high.Length)
|
||||
{
|
||||
throw new ArgumentException("K output span must be at least as long as input", nameof(kOut));
|
||||
}
|
||||
if (dOut.Length < high.Length)
|
||||
{
|
||||
throw new ArgumentException("D output span must be at least as long as input", nameof(dOut));
|
||||
}
|
||||
|
||||
int len = high.Length;
|
||||
if (len == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute highest/lowest via Highest/Lowest batch helpers
|
||||
const int StackallocThreshold = 256;
|
||||
double[]? rentedUpper = null;
|
||||
double[]? rentedLower = null;
|
||||
double[]? rentedDBuf = null;
|
||||
scoped Span<double> upperBuf;
|
||||
scoped Span<double> lowerBuf;
|
||||
|
||||
if (len <= StackallocThreshold)
|
||||
{
|
||||
upperBuf = stackalloc double[len];
|
||||
lowerBuf = stackalloc double[len];
|
||||
}
|
||||
else
|
||||
{
|
||||
rentedUpper = ArrayPool<double>.Shared.Rent(len);
|
||||
rentedLower = ArrayPool<double>.Shared.Rent(len);
|
||||
upperBuf = rentedUpper.AsSpan(0, len);
|
||||
lowerBuf = rentedLower.AsSpan(0, len);
|
||||
}
|
||||
|
||||
// SMA circular buffer for %D
|
||||
scoped Span<double> dBuf;
|
||||
if (dPeriod <= StackallocThreshold)
|
||||
{
|
||||
dBuf = stackalloc double[dPeriod];
|
||||
}
|
||||
else
|
||||
{
|
||||
rentedDBuf = ArrayPool<double>.Shared.Rent(dPeriod);
|
||||
dBuf = rentedDBuf.AsSpan(0, dPeriod);
|
||||
}
|
||||
dBuf.Clear();
|
||||
|
||||
try
|
||||
{
|
||||
Highest.Batch(high, upperBuf, kLength);
|
||||
Lowest.Batch(low, lowerBuf, kLength);
|
||||
|
||||
double dSum = 0.0;
|
||||
int dHead = 0;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
double range = upperBuf[i] - lowerBuf[i];
|
||||
double kVal = range > 0.0 ? 100.0 * (close[i] - lowerBuf[i]) / range : 0.0;
|
||||
|
||||
kOut[i] = kVal;
|
||||
|
||||
if (i == 0)
|
||||
{
|
||||
// Fill entire D buffer with first %K value
|
||||
for (int j = 0; j < dPeriod; j++)
|
||||
{
|
||||
dBuf[j] = kVal;
|
||||
}
|
||||
dSum = kVal * dPeriod;
|
||||
dHead = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
double oldVal = dBuf[dHead];
|
||||
dSum = dSum - oldVal + kVal;
|
||||
dBuf[dHead] = kVal;
|
||||
dHead = (dHead + 1) % dPeriod;
|
||||
}
|
||||
|
||||
dOut[i] = dSum / dPeriod;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (rentedUpper != null)
|
||||
{
|
||||
ArrayPool<double>.Shared.Return(rentedUpper);
|
||||
}
|
||||
if (rentedLower != null)
|
||||
{
|
||||
ArrayPool<double>.Shared.Return(rentedLower);
|
||||
}
|
||||
if (rentedDBuf != null)
|
||||
{
|
||||
ArrayPool<double>.Shared.Return(rentedDBuf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static (TSeries K, TSeries D) Batch(TBarSeries source, int kLength = 14, int dPeriod = 3)
|
||||
{
|
||||
if (source == null || source.Count == 0)
|
||||
{
|
||||
return (new TSeries([], []), new TSeries([], []));
|
||||
}
|
||||
|
||||
int len = source.Count;
|
||||
var tK = new List<long>(len);
|
||||
var vK = new List<double>(len);
|
||||
var tD = new List<long>(len);
|
||||
var vD = new List<double>(len);
|
||||
|
||||
CollectionsMarshal.SetCount(tK, len);
|
||||
CollectionsMarshal.SetCount(vK, len);
|
||||
CollectionsMarshal.SetCount(tD, len);
|
||||
CollectionsMarshal.SetCount(vD, len);
|
||||
|
||||
Batch(source.HighValues, source.LowValues, source.CloseValues,
|
||||
CollectionsMarshal.AsSpan(vK),
|
||||
CollectionsMarshal.AsSpan(vD),
|
||||
kLength, dPeriod);
|
||||
|
||||
var tSpan = CollectionsMarshal.AsSpan(tK);
|
||||
source.Times.CopyTo(tSpan);
|
||||
tSpan.CopyTo(CollectionsMarshal.AsSpan(tD));
|
||||
|
||||
return (new TSeries(tK, vK), new TSeries(tD, vD));
|
||||
}
|
||||
|
||||
public static ((TSeries K, TSeries D) Results, Stoch Indicator) Calculate(
|
||||
TBarSeries source, int kLength = 14, int dPeriod = 3)
|
||||
{
|
||||
var indicator = new Stoch(kLength, dPeriod);
|
||||
var results = indicator.Update(source);
|
||||
return (results, indicator);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
# Stochastic Oscillator (STOCH)
|
||||
|
||||
## Overview
|
||||
|
||||
The Stochastic Oscillator measures the position of the closing price relative to the high-low range over a lookback period. Developed by George C. Lane in the late 1950s, it is one of the most widely used momentum oscillators in technical analysis.
|
||||
|
||||
The indicator produces two lines:
|
||||
- **%K** (Fast Stochastic): Raw position within the range, scaled 0–100
|
||||
- **%D** (Signal line): Simple Moving Average of %K
|
||||
|
||||
## Origin and Sources
|
||||
|
||||
George C. Lane introduced the Stochastic Oscillator based on the observation that closing prices tend to cluster near the high of the trading range during uptrends and near the low during downtrends. The indicator quantifies this tendency.
|
||||
|
||||
**Key references:**
|
||||
- Lane, George C. "Lane's Stochastics." *Technical Analysis of Stocks & Commodities*, 1984
|
||||
- Murphy, John J. *Technical Analysis of the Financial Markets*, 1999
|
||||
- Appel, Gerald & Hitschler, Fred. *Stock Market Trading Systems*, 1980
|
||||
|
||||
## Mathematical Formula
|
||||
|
||||
### Core Calculation
|
||||
|
||||
```
|
||||
%K = 100 × (Close − Lowest Low) / (Highest High − Lowest Low)
|
||||
|
||||
Where:
|
||||
Lowest Low = min(Low[i]) for i ∈ [0, kLength-1]
|
||||
Highest High = max(High[i]) for i ∈ [0, kLength-1]
|
||||
|
||||
%D = SMA(%K, dPeriod)
|
||||
```
|
||||
|
||||
### Edge Case
|
||||
|
||||
When `Highest High = Lowest Low` (zero range), `%K = 0`.
|
||||
|
||||
### Signal Line
|
||||
|
||||
`%D` is computed as a Simple Moving Average of `%K` values using a circular buffer with a running sum for O(1) per-bar computation.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Streaming Path
|
||||
|
||||
The streaming implementation uses **monotonic deques** for O(1) amortized highest-high and lowest-low tracking:
|
||||
|
||||
- **MonotonicDeque** (max): Maintains decreasing order of high values; front always holds the current maximum
|
||||
- **MonotonicDeque** (min): Maintains increasing order of low values; front always holds the current minimum
|
||||
- **Circular buffer** + running sum for SMA(%K → %D)
|
||||
|
||||
Bar correction (`isNew=false`) triggers deque rebuild from the circular buffer, ensuring correct state without allocation.
|
||||
|
||||
### State Management
|
||||
|
||||
```
|
||||
State record struct:
|
||||
DSum — running sum of %K values in the SMA window
|
||||
DHead — circular buffer head index for %D SMA
|
||||
PrevDVal — previous buffer value at DHead (for rollback)
|
||||
LastValidHigh/Low/Close — NaN/Infinity protection
|
||||
```
|
||||
|
||||
The standard `_s` / `_ps` pattern enables bar correction:
|
||||
- `isNew=true`: `_ps = _s`, advance index/count
|
||||
- `isNew=false`: `_s = _ps`, recalculate from previous state
|
||||
|
||||
### Batch Path
|
||||
|
||||
Static `Batch()` methods use `Highest.Batch()` and `Lowest.Batch()` for vectorized min/max computation, with `ArrayPool` for buffers exceeding 256 elements and `stackalloc` for smaller inputs.
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Type | Default | Range | Description |
|
||||
|-----------|------|---------|-------|-------------|
|
||||
| `kLength` | int | 14 | ≥ 1 | Lookback period for highest high / lowest low |
|
||||
| `dPeriod` | int | 3 | ≥ 1 | SMA smoothing period for %D signal line |
|
||||
|
||||
## Performance Profile
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Time complexity (streaming) | O(1) amortized per bar |
|
||||
| Time complexity (batch) | O(n) |
|
||||
| Space complexity | O(kLength + dPeriod) |
|
||||
| Warmup period | kLength bars |
|
||||
| Output range | 0–100 (both %K and %D) |
|
||||
|
||||
## Interpretation
|
||||
|
||||
### Overbought / Oversold
|
||||
|
||||
| Zone | %K Level | Interpretation |
|
||||
|------|----------|----------------|
|
||||
| Overbought | > 80 | Price near top of range — potential reversal |
|
||||
| Neutral | 20–80 | Normal trading range |
|
||||
| Oversold | < 20 | Price near bottom of range — potential reversal |
|
||||
|
||||
### Signal Patterns
|
||||
|
||||
- **%K/%D Crossover**: Bullish when %K crosses above %D; bearish when %K crosses below %D
|
||||
- **Divergence**: Price makes new highs/lows while Stochastic doesn't — potential reversal
|
||||
- **Failure Swings**: %K reaches overbought/oversold then reverses before re-reaching the extreme
|
||||
- **Hook**: Short-term reversal pattern when %K or %D hooks at extremes
|
||||
|
||||
### Fast vs Slow Stochastic
|
||||
|
||||
This implementation is the **Fast Stochastic** where:
|
||||
- `%K` is the raw (unsmoothed) oscillator
|
||||
- `%D` is the SMA of `%K`
|
||||
|
||||
The "Slow Stochastic" smooths both lines: Slow %K = SMA(Fast %K), Slow %D = SMA(Slow %K). Use this implementation with a separate SMA wrapper if slow smoothing is desired.
|
||||
|
||||
## Validation
|
||||
|
||||
| Library | Match | Notes |
|
||||
|---------|-------|-------|
|
||||
| Skender | ✔️ | Via `GetStoch(kLength, dPeriod, smoothPeriods=1)` — smoothPeriods=1 produces Fast %K |
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Zero range**: When all bars in the window have identical H/L, range = 0 and %K = 0 (not 50 or NaN)
|
||||
2. **Fast vs Slow confusion**: Many platforms default to "Slow Stochastic"; this indicator outputs Fast %K
|
||||
3. **Overbought ≠ sell signal**: In strong trends, %K can stay above 80 for extended periods
|
||||
4. **Short lookback noise**: kLength < 5 creates excessive whipsaws in volatile markets
|
||||
5. **SMA warmup for %D**: The first dPeriod bars use the PineScript convention of filling the SMA buffer with the first %K value, not NaN
|
||||
|
||||
## References
|
||||
|
||||
- Lane, G. C. (1984). "Lane's Stochastics." *Technical Analysis of Stocks & Commodities*
|
||||
- Murphy, J. J. (1999). *Technical Analysis of the Financial Markets*. New York Institute of Finance
|
||||
- Achelis, S. B. (2000). *Technical Analysis from A to Z*. McGraw-Hill
|
||||
- [TradingView Stochastic](https://www.tradingview.com/support/solutions/43000502332/)
|
||||
- [StockCharts Stochastic Oscillator](https://school.stockcharts.com/doku.php?id=technical_indicators:stochastic_oscillator_fast_slow_and_full)
|
||||
Reference in New Issue
Block a user