Add validation tests for various volume and momentum indicators

- Introduced Massi validation tests to ensure mathematical properties hold for the Mass Index indicator.
- Added Va validation tests for Volume Accumulation, checking for finite outputs and correct accumulation behavior.
- Implemented Vf validation tests for Volume Force, verifying outputs for rising and falling prices, and ensuring batch and streaming results match.
- Created Vo validation tests for Volume Oscillator, confirming behavior with constant, increasing, and decreasing volumes.
- Developed Vroc validation tests for Volume Rate of Change, validating outputs for constant volume and changes in volume.
- Updated project file to include new momentum indicators (MACD and RSI) in the compilation.
This commit is contained in:
Miha Kralj
2026-02-12 19:43:09 -08:00
parent 92709ef2ed
commit 951842acca
56 changed files with 12350 additions and 359 deletions
@@ -0,0 +1,108 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public sealed class StochfIndicatorTests
{
[Fact]
public void StochfIndicator_Constructor_SetsDefaults()
{
var indicator = new StochfIndicator();
Assert.Equal(5, indicator.KLength);
Assert.Equal(3, indicator.DPeriod);
Assert.True(indicator.ShowColdValues);
Assert.Equal("STOCHF", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void StochfIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new StochfIndicator { KLength = 5, DPeriod = 3 };
Assert.Equal(0, StochfIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void StochfIndicator_ShortName_IncludesParameters()
{
var indicator = new StochfIndicator { KLength = 5, DPeriod = 5 };
indicator.Initialize();
Assert.Contains("STOCHF", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("5", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void StochfIndicator_SourceCodeLink_IsValid()
{
var indicator = new StochfIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Stochf", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void StochfIndicator_Initialize_CreatesInternalStochf()
{
var indicator = new StochfIndicator { KLength = 5, DPeriod = 3 };
indicator.Initialize();
// After init, line series should exist (K, D)
Assert.Equal(2, indicator.LinesSeries.Count);
}
[Fact]
public void StochfIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new StochfIndicator { 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 StochfIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new StochfIndicator { 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 StochfIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("K Length", sortIndex: 1, 1, 500, 1, 0)]
public int KLength { get; set; } = 5;
[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 Stochf _stochf = null!;
private readonly LineSeries _kSeries;
private readonly LineSeries _dSeries;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"STOCHF {KLength},{DPeriod}";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/stochf/Stochf.cs";
public StochfIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "STOCHF";
Description = "Stochastic Fast Oscillator with raw %K and SMA %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()
{
_stochf = new Stochf(KLength, DPeriod);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
_ = _stochf.Update(this.GetInputBar(args), args.IsNewBar());
_kSeries.SetValue(_stochf.K.Value, _stochf.IsHot, ShowColdValues);
_dSeries.SetValue(_stochf.D.Value, _stochf.IsHot, ShowColdValues);
}
}
+568
View File
@@ -0,0 +1,568 @@
using Xunit;
namespace QuanTAlib.Tests;
public sealed class StochfTests
{
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 Stochf(kLength: 0));
Assert.Equal("kLength", ex.ParamName);
}
[Fact]
public void Constructor_InvalidDPeriod_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Stochf(kLength: 5, dPeriod: 0));
Assert.Equal("dPeriod", ex.ParamName);
}
[Fact]
public void Constructor_NegativeKLength_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Stochf(kLength: -5));
Assert.Equal("kLength", ex.ParamName);
}
[Fact]
public void Constructor_NegativeDPeriod_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Stochf(kLength: 5, dPeriod: -1));
Assert.Equal("dPeriod", ex.ParamName);
}
// === B) Basic calculation ===
[Fact]
public void Update_ReturnsTValue()
{
var stochf = new Stochf(kLength: 5, dPeriod: 3);
var bar = new TBar(DateTime.UtcNow, 10, 12, 8, 11, 100);
TValue result = stochf.Update(bar);
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Update_Last_K_D_Accessible()
{
var stochf = new Stochf(kLength: 5, dPeriod: 3);
var bar = new TBar(DateTime.UtcNow, 10, 12, 8, 11, 100);
stochf.Update(bar);
Assert.True(double.IsFinite(stochf.Last.Value));
Assert.True(double.IsFinite(stochf.K.Value));
Assert.True(double.IsFinite(stochf.D.Value));
Assert.NotEmpty(stochf.Name);
}
[Fact]
public void ConstantBars_K_Is_Zero_Or_Defined()
{
var stochf = new Stochf(kLength: 5, dPeriod: 3);
for (int i = 0; i < 20; i++)
{
var bar = new TBar(DateTime.UtcNow.AddMinutes(i), 50, 50, 50, 50, 100);
stochf.Update(bar);
}
// When all H=L=C, range=0, so %K=0
Assert.Equal(0.0, stochf.K.Value);
Assert.Equal(0.0, stochf.D.Value);
}
[Fact]
public void RisingBars_K_Approaches_100()
{
var stochf = new Stochf(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);
stochf.Update(bar);
}
// Close at recent high should produce high %K
Assert.True(stochf.K.Value > 50.0);
}
[Fact]
public void FallingBars_K_Approaches_0()
{
var stochf = new Stochf(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);
stochf.Update(bar);
}
// Close at recent low should produce low %K
Assert.True(stochf.K.Value < 50.0);
}
// === C) State + bar correction ===
[Fact]
public void IsNew_True_Advances_State()
{
var stochf = new Stochf(kLength: 5, dPeriod: 3);
var bars = GenerateBars(10);
for (int i = 0; i < 10; i++)
{
stochf.Update(bars[i], isNew: true);
}
_ = stochf.K.Value;
// Feed one more bar
var nextBar = new TBar(DateTime.UtcNow.AddMinutes(100), 105, 110, 100, 108, 100);
stochf.Update(nextBar, isNew: true);
// State should have advanced — K may differ
Assert.True(double.IsFinite(stochf.K.Value));
}
[Fact]
public void IsNew_False_Rewrites()
{
var stochf = new Stochf(kLength: 5, dPeriod: 3);
var bars = GenerateBars(10);
for (int i = 0; i < 9; i++)
{
stochf.Update(bars[i], isNew: true);
}
stochf.Update(bars[9], isNew: true);
double kAfterNew = stochf.K.Value;
// Update same bar position with different value
var corrected = new TBar(bars[9].Time, 999, 1005, 995, 1000, 100);
stochf.Update(corrected, isNew: false);
double kAfterCorrect = stochf.K.Value;
// Correcting with very different price should change K
Assert.NotEqual(kAfterNew, kAfterCorrect);
}
[Fact]
public void IterativeCorrections_Restore()
{
var stochf = new Stochf(kLength: 5, dPeriod: 3);
var bars = GenerateBars(15);
for (int i = 0; i < 10; i++)
{
stochf.Update(bars[i], isNew: true);
}
_ = stochf.K.Value;
_ = stochf.D.Value;
// Apply correction
stochf.Update(bars[10], isNew: true);
// Roll back with correction
stochf.Update(bars[10], isNew: false);
// Apply same bar again
stochf.Update(bars[10], isNew: false);
// Multiple corrections of the same bar should converge
double kAfter = stochf.K.Value;
Assert.True(double.IsFinite(kAfter));
}
[Fact]
public void Reset_ClearsState()
{
var stochf = new Stochf(kLength: 5, dPeriod: 3);
var bars = GenerateBars(20);
for (int i = 0; i < 20; i++)
{
stochf.Update(bars[i], isNew: true);
}
Assert.True(stochf.IsHot);
stochf.Reset();
Assert.False(stochf.IsHot);
Assert.Equal(default, stochf.Last);
Assert.Equal(default, stochf.K);
Assert.Equal(default, stochf.D);
}
// === D) Warmup/convergence ===
[Fact]
public void IsHot_FlipsAfterKLength()
{
var stochf = new Stochf(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);
stochf.Update(bar);
Assert.False(stochf.IsHot);
}
var bar5 = new TBar(DateTime.UtcNow.AddMinutes(4), 104, 106, 102, 105, 100);
stochf.Update(bar5);
Assert.True(stochf.IsHot);
}
[Fact]
public void WarmupPeriod_MatchesKLength()
{
var stochf = new Stochf(kLength: 10, dPeriod: 3);
Assert.Equal(10, stochf.WarmupPeriod);
}
// === E) Robustness ===
[Fact]
public void NaN_UsesLastValid()
{
var stochf = new Stochf(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);
stochf.Update(bar);
}
_ = stochf.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);
stochf.Update(nanBar);
Assert.True(double.IsFinite(stochf.K.Value));
}
[Fact]
public void Infinity_UsesLastValid()
{
var stochf = new Stochf(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);
stochf.Update(bar);
}
var infBar = new TBar(DateTime.UtcNow.AddMinutes(10), double.PositiveInfinity, double.PositiveInfinity,
double.NegativeInfinity, double.PositiveInfinity, 0);
stochf.Update(infBar);
Assert.True(double.IsFinite(stochf.K.Value));
}
[Fact]
public void AllNaN_ReturnsNaN()
{
var stochf = new Stochf(kLength: 5, dPeriod: 3);
// No valid data ever
var nanBar = new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 0);
stochf.Update(nanBar);
Assert.True(double.IsNaN(stochf.K.Value));
Assert.True(double.IsNaN(stochf.D.Value));
}
// === F) Consistency ===
[Fact]
public void StreamingMatchesBatch()
{
const int kLength = 5;
const int dPeriod = 3;
var bars = GenerateBars(100);
// Streaming
var stochfStream = new Stochf(kLength: kLength, dPeriod: dPeriod);
var streamK = new double[100];
var streamD = new double[100];
for (int i = 0; i < 100; i++)
{
stochfStream.Update(bars[i], isNew: true);
streamK[i] = stochfStream.K.Value;
streamD[i] = stochfStream.D.Value;
}
// Batch (TBarSeries)
var (batchK, batchD) = Stochf.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 = 5;
const int dPeriod = 3;
var bars = GenerateBars(100);
// TBarSeries batch
var (tbK, tbD) = Stochf.Batch(bars, kLength, dPeriod);
// Span batch
var kOut = new double[100];
var dOut = new double[100];
Stochf.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 = 5;
const int dPeriod = 3;
var bars = GenerateBars(50);
var stochfDirect = new Stochf(kLength: kLength, dPeriod: dPeriod);
var directK = new double[50];
for (int i = 0; i < 50; i++)
{
stochfDirect.Update(bars[i], isNew: true);
directK[i] = stochfDirect.K.Value;
}
// Event-based via TBarSeries subscription
var barSeries = new TBarSeries();
var stochfEvent = new Stochf(barSeries, kLength: kLength, dPeriod: dPeriod);
var eventK = new List<double>();
stochfEvent.Pub += (object? _, in TValueEventArgs e) => eventK.Add(e.Value.Value);
// Re-prime so events fire from index 0
stochfEvent.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 = 5;
const int dPeriod = 3;
var bars = GenerateBars(100);
// Streaming
var stochfStream = new Stochf(kLength: kLength, dPeriod: dPeriod);
for (int i = 0; i < 100; i++)
{
stochfStream.Update(bars[i], isNew: true);
}
// Update(TBarSeries)
var stochfBatch = new Stochf(kLength: kLength, dPeriod: dPeriod);
var (kSeries, dSeries) = stochfBatch.Update(bars);
Assert.Equal(stochfStream.K.Value, kSeries.Values[^1], 10);
Assert.Equal(stochfStream.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>();
Stochf.Batch(ReadOnlySpan<double>.Empty, ReadOnlySpan<double>.Empty,
ReadOnlySpan<double>.Empty, kOut.AsSpan(), dOut.AsSpan(), 5, 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>(() =>
Stochf.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>(() =>
Stochf.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>(() =>
Stochf.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>(() =>
Stochf.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>(() =>
Stochf.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
Stochf.Batch(bars.HighValues, bars.LowValues, bars.CloseValues,
kOut.AsSpan(), dOut.AsSpan(), 5, 3);
Assert.True(double.IsFinite(kOut[^1]));
Assert.True(double.IsFinite(dOut[^1]));
}
// === H) Chainability ===
[Fact]
public void Pub_FiresOnUpdate()
{
var stochf = new Stochf(kLength: 5, dPeriod: 3);
int fireCount = 0;
stochf.Pub += (object? _, in TValueEventArgs _) => fireCount++;
var bar = new TBar(DateTime.UtcNow, 10, 12, 8, 11, 100);
stochf.Update(bar);
Assert.Equal(1, fireCount);
}
[Fact]
public void TValue_Overload_Works()
{
var stochf = new Stochf(kLength: 5, dPeriod: 3);
for (int i = 0; i < 10; i++)
{
stochf.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(stochf.K.Value));
}
[Fact]
public void Name_MatchesParameters()
{
var stochf = new Stochf(kLength: 5, dPeriod: 3);
Assert.Equal("StochF(5,3)", stochf.Name);
}
[Fact]
public void Calculate_ReturnsResultsAndIndicator()
{
var bars = GenerateBars(50);
var (results, indicator) = Stochf.Calculate(bars, kLength: 5, 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 stochf = new Stochf(kLength: 5, dPeriod: 3);
var bars = GenerateBars(100);
for (int i = 0; i < 100; i++)
{
stochf.Update(bars[i], isNew: true);
double k = stochf.K.Value;
if (double.IsFinite(k))
{
Assert.InRange(k, -0.001, 100.001);
}
}
}
[Fact]
public void CloseAtHigh_K_Is_100()
{
var stochf = new Stochf(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);
stochf.Update(bar);
}
// Close at the absolute highest high with range present
var topBar = new TBar(DateTime.UtcNow.AddMinutes(4), 100, 110, 90, 110, 100);
stochf.Update(topBar);
Assert.Equal(100.0, stochf.K.Value, 6);
}
[Fact]
public void CloseAtLow_K_Is_0()
{
var stochf = new Stochf(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);
stochf.Update(bar);
}
// Close at the absolute lowest low with range present
var botBar = new TBar(DateTime.UtcNow.AddMinutes(4), 100, 110, 90, 90, 100);
stochf.Update(botBar);
Assert.Equal(0.0, stochf.K.Value, 6);
}
}
@@ -0,0 +1,301 @@
using Skender.Stock.Indicators;
using Xunit;
namespace QuanTAlib.Tests;
/// <summary>
/// Stochastic Fast Oscillator validation tests.
/// Cross-validates against Skender.Stock.Indicators.GetStoch with smoothPeriods=1
/// (Fast Stochastic matches our raw %K), TALib.NETCore StochF,
/// plus self-consistency checks.
/// </summary>
public sealed class StochfValidationTests : 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 = 5;
const int dPeriod = 3;
var stochf = new Stochf(kLength, dPeriod);
for (int i = 0; i < series.Count; i++)
{
stochf.Update(series[i]);
}
var (batchK, batchD) = Stochf.Batch(series, kLength, dPeriod);
Assert.Equal(stochf.K.Value, batchK[^1].Value, 1e-6);
Assert.Equal(stochf.D.Value, batchD[^1].Value, 1e-6);
}
// --- B) Span matches TBarSeries ---
[Fact]
public void Span_Matches_TBarSeries()
{
var series = GenerateSeries(200);
const int kLength = 5;
const int dPeriod = 3;
var (tbK, tbD) = Stochf.Batch(series, kLength, dPeriod);
var kOut = new double[series.Count];
var dOut = new double[series.Count];
Stochf.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 = 5;
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) = Stochf.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 stochf = new Stochf(kLength, dPeriod);
for (int i = 0; i < bars.Count; i++)
{
stochf.Update(bars[i]);
}
// Close at recent high → %K should be near 100
Assert.True(stochf.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 stochf = new Stochf(kLength, dPeriod);
for (int i = 0; i < bars.Count; i++)
{
stochf.Update(bars[i]);
}
// Close at recent low → %K should be near 0
Assert.True(stochf.K.Value < 20.0);
}
// --- E) Cross-validation with Skender (smoothPeriods=1 == Fast) ---
[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 = 5;
const int dPeriod = 3;
var (qK, qD) = Stochf.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) Cross-validation with TALib StochF ---
[Fact]
public void TALib_StochF_K_Matches()
{
const int kLength = 5;
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];
var retCode = TALib.Functions.StochF(hData, lData, cData, 0..^0,
taK, taD, out var outRange, kLength, dPeriod);
Assert.Equal(TALib.Core.RetCode.Success, retCode);
var (offset, length) = outRange.GetOffsetAndLength(taK.Length);
var (qK, qD) = Stochf.Batch(_data.Bars, kLength, dPeriod);
int matched = 0;
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]);
matched++;
if (errK > 1e-6 || errD > 1e-6)
{
mismatches++;
}
}
Assert.True(matched > 0, "No TALib results to compare");
double mismatchRate = (double)mismatches / matched;
Assert.True(mismatchRate < 0.05, $"TALib mismatch rate {mismatchRate:P2} exceeds 5% ({mismatches}/{matched})");
}
// --- G) Determinism ---
[Fact]
public void Deterministic_Across_Runs()
{
var series = GenerateSeries(200, seed: 99);
const int kLength = 5;
const int dPeriod = 3;
var (k1, d1) = Stochf.Batch(series, kLength, dPeriod);
var (k2, d2) = Stochf.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);
}
}
// --- H) Multi-period consistency ---
[Fact]
public void Different_Periods_Produce_Different_Results()
{
var series = GenerateSeries(100);
var (k5, _) = Stochf.Batch(series, kLength: 5, dPeriod: 3);
var (k20, _) = Stochf.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);
}
// --- I) Calculate returns both results and indicator ---
[Fact]
public void Calculate_Produces_Consistent_Results()
{
var series = GenerateSeries(100);
const int kLength = 5;
const int dPeriod = 3;
var (results, indicator) = Stochf.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));
}
}
+433
View File
@@ -0,0 +1,433 @@
// STOCHF: Stochastic Fast Oscillator
// Fast %K = 100 * (close - lowestLow) / (highestHigh - lowestLow)
// Fast %D = SMA(Fast %K, dPeriod)
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// STOCHF: Stochastic Fast Oscillator (%K and %D).
/// %K = 100 * (close - lowestLow) / (highestHigh - lowestLow).
/// %D = SMA(%K, dPeriod).
/// Unsmoothed variant of the Stochastic Oscillator — %K is raw (no additional smoothing).
/// 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 Stochf : ITValuePublisher
{
private const int DefaultKLength = 5;
private const int DefaultDPeriod = 3;
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 Stochf(int kLength = DefaultKLength, int dPeriod = DefaultDPeriod)
{
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 = $"StochF({kLength},{dPeriod})";
WarmupPeriod = kLength;
_barHandler = HandleBar;
}
public Stochf(TBarSeries source, int kLength = DefaultKLength, int dPeriod = DefaultDPeriod)
: 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 = DefaultDPeriod)
{
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 = DefaultKLength, int dPeriod = DefaultDPeriod)
{
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, Stochf Indicator) Calculate(
TBarSeries source, int kLength = DefaultKLength, int dPeriod = DefaultDPeriod)
{
var indicator = new Stochf(kLength, dPeriod);
var results = indicator.Update(source);
return (results, indicator);
}
}
+138
View File
@@ -0,0 +1,138 @@
# Stochastic Fast Oscillator (STOCHF)
## Overview
The Stochastic Fast Oscillator is the unsmoothed variant of the classic Stochastic Oscillator. It measures the position of the closing price relative to the high-low range over a lookback period, producing a raw (fast) %K line and its SMA-smoothed %D signal line.
Unlike the standard Stochastic (STOCH), which may apply additional SMA smoothing to %K, StochF outputs the raw %K directly — making it more responsive to price changes but also noisier.
The indicator produces two lines:
- **%K** (Fast %K): Raw position within the range, scaled 0100
- **%D** (Signal line): Simple Moving Average of %K
## Origin and Sources
George C. Lane introduced the Stochastic Oscillator in the late 1950s. The "Fast" variant is the original unsmoothed form, while the "Slow" variant applies additional SMA smoothing to reduce noise. Most modern platforms offer both versions; TA-Lib specifically separates them as `STOCH` (slow) and `STOCHF` (fast).
**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 | 5 | ≥ 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 | 0100 (both %K and %D) |
## Interpretation
### Overbought / Oversold
| Zone | %K Level | Interpretation |
|------|----------|----------------|
| Overbought | > 80 | Price near top of range — potential reversal |
| Neutral | 2080 | 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 StochF 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
### StochF vs STOCH (Slow Stochastic)
This implementation is the **Fast Stochastic** where:
- `%K` is the raw (unsmoothed) oscillator
- `%D` is the SMA of `%K`
The "Slow Stochastic" (STOCH) additionally smooths %K with an SMA before computing %D. StochF is more responsive but generates more false signals in choppy markets.
## Validation
| Library | Match | Notes |
|---------|-------|-------|
| Skender | ✔️ | Via `GetStoch(kLength, dPeriod, smoothPeriods=1)` — smoothPeriods=1 produces Fast %K |
| TALib | ✔️ | Via `TALib.Functions.StochF(high, low, close, ...)` — dedicated Fast Stochastic function |
## 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"; StochF outputs the raw unsmoothed %K
3. **Overbought ≠ sell signal**: In strong trends, %K can stay above 80 for extended periods
4. **Short lookback noise**: kLength < 3 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
6. **Default period difference**: StochF defaults to kLength=5 (shorter than STOCH's kLength=14) for faster response
## 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)