mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-21 12:08:05 +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,113 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using QuanTAlib;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class KdjIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void KdjIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new KdjIndicator();
|
||||
|
||||
Assert.Equal(9, indicator.Length);
|
||||
Assert.Equal(3, indicator.Signal);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("KDJ", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KdjIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new KdjIndicator { Length = 14, Signal = 5 };
|
||||
|
||||
Assert.Equal(0, KdjIndicator.MinHistoryDepths);
|
||||
IWatchlistIndicator watchlistIndicator = indicator;
|
||||
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KdjIndicator_ShortName_IncludesParameters()
|
||||
{
|
||||
var indicator = new KdjIndicator { Length = 14, Signal = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Contains("KDJ", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("14", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("5", indicator.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KdjIndicator_SourceCodeLink_IsValid()
|
||||
{
|
||||
var indicator = new KdjIndicator();
|
||||
|
||||
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
Assert.Contains("Kdj.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KdjIndicator_Initialize_CreatesInternalKdj()
|
||||
{
|
||||
var indicator = new KdjIndicator { Length = 9, Signal = 3 };
|
||||
|
||||
indicator.Initialize();
|
||||
|
||||
// After init, line series should exist (K, D, J)
|
||||
Assert.Equal(3, indicator.LinesSeries.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KdjIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new KdjIndicator { Length = 5, Signal = 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);
|
||||
double j = indicator.LinesSeries[2].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(k));
|
||||
Assert.True(double.IsFinite(d));
|
||||
Assert.True(double.IsFinite(j));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KdjIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new KdjIndicator { Length = 5, Signal = 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);
|
||||
double j = indicator.LinesSeries[2].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(k));
|
||||
Assert.True(double.IsFinite(d));
|
||||
Assert.True(double.IsFinite(j));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class KdjIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Length", sortIndex: 1, 1, 500, 1, 0)]
|
||||
public int Length { get; set; } = 9;
|
||||
|
||||
[InputParameter("Signal", sortIndex: 2, 1, 50, 1, 0)]
|
||||
public int Signal { get; set; } = 3;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Kdj _kdj = null!;
|
||||
private readonly LineSeries _kSeries;
|
||||
private readonly LineSeries _dSeries;
|
||||
private readonly LineSeries _jSeries;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"KDJ {Length},{Signal}";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/kdj/Kdj.Quantower.cs";
|
||||
|
||||
public KdjIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
Name = "KDJ";
|
||||
Description = "Enhanced Stochastic Oscillator with K, D, J lines";
|
||||
|
||||
_kSeries = new LineSeries(name: "K", color: Color.Blue, width: 2, style: LineStyle.Solid);
|
||||
_dSeries = new LineSeries(name: "D", color: Color.Red, width: 2, style: LineStyle.Solid);
|
||||
_jSeries = new LineSeries(name: "J", color: Color.Yellow, width: 2, style: LineStyle.Solid);
|
||||
|
||||
AddLineSeries(_kSeries);
|
||||
AddLineSeries(_dSeries);
|
||||
AddLineSeries(_jSeries);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnInit()
|
||||
{
|
||||
_kdj = new Kdj(Length, Signal);
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
TValue result = _kdj.Update(this.GetInputBar(args), args.IsNewBar());
|
||||
|
||||
_kSeries.SetValue(_kdj.K.Value, _kdj.IsHot, ShowColdValues);
|
||||
_dSeries.SetValue(_kdj.D.Value, _kdj.IsHot, ShowColdValues);
|
||||
_jSeries.SetValue(result.Value, _kdj.IsHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class KdjTests
|
||||
{
|
||||
// ── A) Constructor validation ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ValidParameters()
|
||||
{
|
||||
var kdj = new Kdj(length: 9, signal: 3);
|
||||
|
||||
Assert.NotNull(kdj);
|
||||
Assert.Equal("Kdj(9,3)", kdj.Name);
|
||||
Assert.Equal(11, kdj.WarmupPeriod);
|
||||
Assert.False(kdj.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidLength_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Kdj(length: 0, signal: 3));
|
||||
Assert.Equal("length", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativeLength_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Kdj(length: -5, signal: 3));
|
||||
Assert.Equal("length", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InvalidSignal_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Kdj(length: 9, signal: 0));
|
||||
Assert.Equal("signal", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativeSignal_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Kdj(length: 9, signal: -1));
|
||||
Assert.Equal("signal", ex.ParamName);
|
||||
}
|
||||
|
||||
// ── B) Basic calculation ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Update_ReturnsTValue()
|
||||
{
|
||||
var kdj = new Kdj(length: 3, signal: 2);
|
||||
DateTime time = DateTime.UtcNow;
|
||||
|
||||
var result = kdj.Update(new TBar(time, 100, 110, 90, 105, 1000));
|
||||
|
||||
Assert.IsType<TValue>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Last_K_D_Accessible()
|
||||
{
|
||||
var kdj = new Kdj(length: 3, signal: 2);
|
||||
DateTime time = DateTime.UtcNow;
|
||||
|
||||
kdj.Update(new TBar(time, 100, 110, 90, 105, 1000));
|
||||
|
||||
Assert.True(double.IsFinite(kdj.Last.Value));
|
||||
Assert.True(double.IsFinite(kdj.K.Value));
|
||||
Assert.True(double.IsFinite(kdj.D.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Name_ContainsKdj()
|
||||
{
|
||||
var kdj = new Kdj(length: 14, signal: 5);
|
||||
Assert.Contains("Kdj", kdj.Name, StringComparison.Ordinal);
|
||||
Assert.Contains("14", kdj.Name, StringComparison.Ordinal);
|
||||
Assert.Contains("5", kdj.Name, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConstantPrice_KDConvergeToFifty()
|
||||
{
|
||||
var kdj = new Kdj(length: 3, signal: 2);
|
||||
DateTime time = DateTime.UtcNow;
|
||||
|
||||
// With constant OHLC, range = 0, RSV = 50
|
||||
// Need enough iterations for exponential warmup compensator to converge
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
kdj.Update(new TBar(time.AddSeconds(i), 100, 100, 100, 100, 1000));
|
||||
}
|
||||
|
||||
Assert.Equal(50.0, kdj.K.Value, 1e-3);
|
||||
Assert.Equal(50.0, kdj.D.Value, 1e-3);
|
||||
// J = 3*50 - 2*50 = 50
|
||||
Assert.Equal(50.0, kdj.Last.Value, 1e-3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseAtHigh_KConvergesToHundred()
|
||||
{
|
||||
var kdj = new Kdj(length: 3, signal: 2);
|
||||
DateTime time = DateTime.UtcNow;
|
||||
|
||||
// Close always at the high of the range => RSV = 100
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
kdj.Update(new TBar(time.AddSeconds(i), 100, 110, 90, 110, 1000));
|
||||
}
|
||||
|
||||
Assert.True(kdj.K.Value > 99.0);
|
||||
Assert.True(kdj.D.Value > 99.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CloseAtLow_KConvergesToZero()
|
||||
{
|
||||
var kdj = new Kdj(length: 3, signal: 2);
|
||||
DateTime time = DateTime.UtcNow;
|
||||
|
||||
// Close always at the low of the range => RSV = 0
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
kdj.Update(new TBar(time.AddSeconds(i), 100, 110, 90, 90, 1000));
|
||||
}
|
||||
|
||||
Assert.True(kdj.K.Value < 1.0);
|
||||
Assert.True(kdj.D.Value < 1.0);
|
||||
}
|
||||
|
||||
// ── C) State + bar correction ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void IsNew_True_AdvancesState()
|
||||
{
|
||||
var kdj = new Kdj(length: 3, signal: 2);
|
||||
DateTime time = DateTime.UtcNow;
|
||||
|
||||
kdj.Update(new TBar(time, 100, 110, 90, 105, 1000), isNew: true);
|
||||
double k1 = kdj.K.Value;
|
||||
|
||||
kdj.Update(new TBar(time.AddSeconds(1), 101, 115, 95, 112, 1000), isNew: true);
|
||||
double k2 = kdj.K.Value;
|
||||
|
||||
Assert.NotEqual(k1, k2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNew_False_RewritesCurrentBar()
|
||||
{
|
||||
var kdj = new Kdj(length: 3, signal: 2);
|
||||
DateTime time = DateTime.UtcNow;
|
||||
|
||||
kdj.Update(new TBar(time, 100, 110, 90, 105, 1000), isNew: true);
|
||||
kdj.Update(new TBar(time.AddSeconds(1), 101, 111, 91, 106, 1000), isNew: true);
|
||||
kdj.Update(new TBar(time.AddSeconds(2), 102, 112, 92, 107, 1000), isNew: true);
|
||||
|
||||
double kBefore = kdj.K.Value;
|
||||
double dBefore = kdj.D.Value;
|
||||
|
||||
// Correct current bar with different close
|
||||
kdj.Update(new TBar(time.AddSeconds(2), 102, 120, 85, 115, 1000), isNew: false);
|
||||
|
||||
double kAfter = kdj.K.Value;
|
||||
double dAfter = kdj.D.Value;
|
||||
|
||||
Assert.NotEqual(kBefore, kAfter);
|
||||
Assert.NotEqual(dBefore, dAfter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IterativeCorrections_RestoreState()
|
||||
{
|
||||
var kdj = new Kdj(length: 5, signal: 3);
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42);
|
||||
|
||||
TBar remembered = default;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
remembered = gbm.Next(isNew: true);
|
||||
kdj.Update(remembered, isNew: true);
|
||||
}
|
||||
|
||||
double snapK = kdj.K.Value;
|
||||
double snapD = kdj.D.Value;
|
||||
double snapJ = kdj.Last.Value;
|
||||
|
||||
// Several corrections
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var corrected = gbm.Next(isNew: false);
|
||||
kdj.Update(corrected, isNew: false);
|
||||
}
|
||||
|
||||
// Restore original bar
|
||||
kdj.Update(remembered, isNew: false);
|
||||
|
||||
Assert.Equal(snapK, kdj.K.Value, 1e-10);
|
||||
Assert.Equal(snapD, kdj.D.Value, 1e-10);
|
||||
Assert.Equal(snapJ, kdj.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var kdj = new Kdj(length: 5, signal: 3);
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 7);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
kdj.Update(gbm.Next(isNew: true), isNew: true);
|
||||
}
|
||||
|
||||
Assert.True(kdj.IsHot);
|
||||
|
||||
kdj.Reset();
|
||||
|
||||
Assert.False(kdj.IsHot);
|
||||
Assert.Equal(0.0, kdj.Last.Value);
|
||||
Assert.Equal(0.0, kdj.K.Value);
|
||||
Assert.Equal(0.0, kdj.D.Value);
|
||||
}
|
||||
|
||||
// ── D) Warmup / convergence ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void IsHot_FlipsAfterLengthBars()
|
||||
{
|
||||
var kdj = new Kdj(length: 5, signal: 3);
|
||||
DateTime time = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
kdj.Update(new TBar(time.AddSeconds(i), 100 + i, 101 + i, 99 + i, 100 + i, 1000));
|
||||
Assert.False(kdj.IsHot);
|
||||
}
|
||||
|
||||
kdj.Update(new TBar(time.AddSeconds(4), 104, 105, 103, 104, 1000));
|
||||
Assert.True(kdj.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WarmupPeriod_EqualsLengthPlusSignalMinusOne()
|
||||
{
|
||||
var kdj = new Kdj(length: 9, signal: 3);
|
||||
Assert.Equal(11, kdj.WarmupPeriod);
|
||||
|
||||
var kdj2 = new Kdj(length: 14, signal: 5);
|
||||
Assert.Equal(18, kdj2.WarmupPeriod);
|
||||
}
|
||||
|
||||
// ── E) Robustness (NaN / Infinity) ─────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void NaN_HighUsesLastValid()
|
||||
{
|
||||
var kdj = new Kdj(length: 3, signal: 2);
|
||||
DateTime time = DateTime.UtcNow;
|
||||
|
||||
kdj.Update(new TBar(time, 100, 110, 90, 105, 1000));
|
||||
kdj.Update(new TBar(time.AddSeconds(1), 101, 111, 91, 106, 1000));
|
||||
var result = kdj.Update(new TBar(time.AddSeconds(2), 102, double.NaN, 92, 107, 1000));
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
Assert.True(double.IsFinite(kdj.K.Value));
|
||||
Assert.True(double.IsFinite(kdj.D.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_LowUsesLastValid()
|
||||
{
|
||||
var kdj = new Kdj(length: 3, signal: 2);
|
||||
DateTime time = DateTime.UtcNow;
|
||||
|
||||
kdj.Update(new TBar(time, 100, 110, 90, 105, 1000));
|
||||
kdj.Update(new TBar(time.AddSeconds(1), 101, 111, 91, 106, 1000));
|
||||
var result = kdj.Update(new TBar(time.AddSeconds(2), 102, 112, double.NaN, 107, 1000));
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NaN_CloseUsesLastValid()
|
||||
{
|
||||
var kdj = new Kdj(length: 3, signal: 2);
|
||||
DateTime time = DateTime.UtcNow;
|
||||
|
||||
kdj.Update(new TBar(time, 100, 110, 90, 105, 1000));
|
||||
kdj.Update(new TBar(time.AddSeconds(1), 101, 111, 91, 106, 1000));
|
||||
var result = kdj.Update(new TBar(time.AddSeconds(2), 102, 112, 92, double.NaN, 1000));
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Infinity_HandledGracefully()
|
||||
{
|
||||
var kdj = new Kdj(length: 3, signal: 2);
|
||||
DateTime time = DateTime.UtcNow;
|
||||
|
||||
kdj.Update(new TBar(time, 100, 110, 90, 105, 1000));
|
||||
kdj.Update(new TBar(time.AddSeconds(1), 101, 111, 91, 106, 1000));
|
||||
var result = kdj.Update(new TBar(time.AddSeconds(2), 102, double.PositiveInfinity, 92, 107, 1000));
|
||||
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BatchNaN_Safe()
|
||||
{
|
||||
var kdj = new Kdj(length: 3, signal: 2);
|
||||
DateTime time = DateTime.UtcNow;
|
||||
|
||||
// All NaN inputs at the start
|
||||
var result = kdj.Update(new TBar(time, double.NaN, double.NaN, double.NaN, double.NaN, 1000));
|
||||
Assert.True(double.IsNaN(result.Value));
|
||||
|
||||
// Then valid data
|
||||
result = kdj.Update(new TBar(time.AddSeconds(1), 100, 110, 90, 105, 1000));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
|
||||
// ── F) Consistency (4 API modes) ───────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void AllFourModes_ProduceConsistentResults()
|
||||
{
|
||||
const int length = 9;
|
||||
const int signal = 3;
|
||||
int barCount = 50;
|
||||
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 123);
|
||||
var bars = new TBarSeries();
|
||||
for (int i = 0; i < barCount; i++)
|
||||
{
|
||||
bars.Add(gbm.Next(isNew: true));
|
||||
}
|
||||
|
||||
// Mode 1: Streaming
|
||||
var streamKdj = new Kdj(length, signal);
|
||||
for (int i = 0; i < barCount; i++)
|
||||
{
|
||||
streamKdj.Update(bars[i], isNew: true);
|
||||
}
|
||||
double streamK = streamKdj.K.Value;
|
||||
double streamD = streamKdj.D.Value;
|
||||
double streamJ = streamKdj.Last.Value;
|
||||
|
||||
// Mode 2: Batch via instance Update(TBarSeries)
|
||||
var batchKdj = new Kdj(length, signal);
|
||||
var (bK, bD, bJ) = batchKdj.Update(bars);
|
||||
double batchK = bK.Values[^1];
|
||||
double batchD = bD.Values[^1];
|
||||
double batchJ = bJ.Values[^1];
|
||||
|
||||
// Mode 3: Static Batch
|
||||
var (sK, sD, sJ) = Kdj.Batch(bars, length, signal);
|
||||
double staticK = sK.Values[^1];
|
||||
double staticD = sD.Values[^1];
|
||||
double staticJ = sJ.Values[^1];
|
||||
|
||||
// Mode 4: Static Calculate
|
||||
var ((cK, cD, cJ), _) = Kdj.Calculate(bars, length, signal);
|
||||
double calcK = cK.Values[^1];
|
||||
double calcD = cD.Values[^1];
|
||||
double calcJ = cJ.Values[^1];
|
||||
|
||||
// All modes must produce same results
|
||||
Assert.Equal(streamK, batchK, 1e-10);
|
||||
Assert.Equal(streamD, batchD, 1e-10);
|
||||
Assert.Equal(streamJ, batchJ, 1e-10);
|
||||
|
||||
Assert.Equal(streamK, staticK, 1e-10);
|
||||
Assert.Equal(streamD, staticD, 1e-10);
|
||||
Assert.Equal(streamJ, staticJ, 1e-10);
|
||||
|
||||
Assert.Equal(streamK, calcK, 1e-10);
|
||||
Assert.Equal(streamD, calcD, 1e-10);
|
||||
Assert.Equal(streamJ, calcJ, 1e-10);
|
||||
}
|
||||
|
||||
// ── G) Span API tests ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_InvalidLength_Throws()
|
||||
{
|
||||
double[] high = [1, 2, 3];
|
||||
double[] low = [0.5, 1.5, 2.5];
|
||||
double[] close = [0.8, 1.8, 2.8];
|
||||
double[] kOut = new double[3];
|
||||
double[] dOut = new double[3];
|
||||
double[] jOut = new double[3];
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Kdj.Batch(high, low, close, kOut, dOut, jOut, 0, 3));
|
||||
Assert.Equal("length", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_InvalidSignal_Throws()
|
||||
{
|
||||
double[] high = [1, 2, 3];
|
||||
double[] low = [0.5, 1.5, 2.5];
|
||||
double[] close = [0.8, 1.8, 2.8];
|
||||
double[] kOut = new double[3];
|
||||
double[] dOut = new double[3];
|
||||
double[] jOut = new double[3];
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Kdj.Batch(high, low, close, kOut, dOut, jOut, 3, 0));
|
||||
Assert.Equal("signal", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_MismatchedInputs_Throws()
|
||||
{
|
||||
double[] high = [1, 2, 3];
|
||||
double[] low = [0.5, 1.5];
|
||||
double[] close = [0.8, 1.8, 2.8];
|
||||
double[] kOut = new double[3];
|
||||
double[] dOut = new double[3];
|
||||
double[] jOut = new double[3];
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Kdj.Batch(high, low, close, kOut, dOut, jOut, 3, 3));
|
||||
Assert.Equal("high", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_ShortKOutput_Throws()
|
||||
{
|
||||
double[] high = [1, 2, 3];
|
||||
double[] low = [0.5, 1.5, 2.5];
|
||||
double[] close = [0.8, 1.8, 2.8];
|
||||
double[] kOut = new double[2]; // too short
|
||||
double[] dOut = new double[3];
|
||||
double[] jOut = new double[3];
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Kdj.Batch(high, low, close, kOut, dOut, jOut, 3, 3));
|
||||
Assert.Equal("kOut", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_ShortDOutput_Throws()
|
||||
{
|
||||
double[] high = [1, 2, 3];
|
||||
double[] low = [0.5, 1.5, 2.5];
|
||||
double[] close = [0.8, 1.8, 2.8];
|
||||
double[] kOut = new double[3];
|
||||
double[] dOut = new double[2]; // too short
|
||||
double[] jOut = new double[3];
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Kdj.Batch(high, low, close, kOut, dOut, jOut, 3, 3));
|
||||
Assert.Equal("dOut", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_ShortJOutput_Throws()
|
||||
{
|
||||
double[] high = [1, 2, 3];
|
||||
double[] low = [0.5, 1.5, 2.5];
|
||||
double[] close = [0.8, 1.8, 2.8];
|
||||
double[] kOut = new double[3];
|
||||
double[] dOut = new double[3];
|
||||
double[] jOut = new double[2]; // too short
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Kdj.Batch(high, low, close, kOut, dOut, jOut, 3, 3));
|
||||
Assert.Equal("jOut", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_MatchesStreaming()
|
||||
{
|
||||
int barCount = 30;
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 77);
|
||||
var bars = new TBarSeries();
|
||||
for (int i = 0; i < barCount; i++)
|
||||
{
|
||||
bars.Add(gbm.Next(isNew: true));
|
||||
}
|
||||
|
||||
// Streaming
|
||||
var kdj = new Kdj(length: 5, signal: 3);
|
||||
for (int i = 0; i < barCount; i++)
|
||||
{
|
||||
kdj.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
// Span
|
||||
double[] kOut = new double[barCount];
|
||||
double[] dOut = new double[barCount];
|
||||
double[] jOut = new double[barCount];
|
||||
Kdj.Batch(bars.HighValues, bars.LowValues, bars.CloseValues,
|
||||
kOut, dOut, jOut, 5, 3);
|
||||
|
||||
Assert.Equal(kdj.K.Value, kOut[^1], 1e-10);
|
||||
Assert.Equal(kdj.D.Value, dOut[^1], 1e-10);
|
||||
Assert.Equal(kdj.Last.Value, jOut[^1], 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_Span_LargeData_NoStackOverflow()
|
||||
{
|
||||
int barCount = 1000;
|
||||
double[] high = new double[barCount];
|
||||
double[] low = new double[barCount];
|
||||
double[] close = new double[barCount];
|
||||
double[] kOut = new double[barCount];
|
||||
double[] dOut = new double[barCount];
|
||||
double[] jOut = new double[barCount];
|
||||
|
||||
for (int i = 0; i < barCount; i++)
|
||||
{
|
||||
high[i] = 100.0 + i * 0.1;
|
||||
low[i] = 99.0 + i * 0.1;
|
||||
close[i] = 99.5 + i * 0.1;
|
||||
}
|
||||
|
||||
// Should not throw StackOverflowException (uses ArrayPool for > 256)
|
||||
Kdj.Batch(high, low, close, kOut, dOut, jOut, 14, 3);
|
||||
|
||||
Assert.True(double.IsFinite(kOut[^1]));
|
||||
Assert.True(double.IsFinite(dOut[^1]));
|
||||
Assert.True(double.IsFinite(jOut[^1]));
|
||||
}
|
||||
|
||||
// ── H) Chainability ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Pub_EventFires()
|
||||
{
|
||||
var kdj = new Kdj(length: 3, signal: 2);
|
||||
int fired = 0;
|
||||
kdj.Pub += (object? _, in TValueEventArgs _) => fired++;
|
||||
|
||||
DateTime time = DateTime.UtcNow;
|
||||
kdj.Update(new TBar(time, 100, 110, 90, 105, 1000));
|
||||
kdj.Update(new TBar(time.AddSeconds(1), 101, 111, 91, 106, 1000));
|
||||
|
||||
Assert.Equal(2, fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventBasedChaining_Works()
|
||||
{
|
||||
var bars = new TBarSeries();
|
||||
var kdj = new Kdj(bars, length: 5, signal: 3);
|
||||
|
||||
int fired = 0;
|
||||
kdj.Pub += (object? _, in TValueEventArgs _) => fired++;
|
||||
|
||||
DateTime time = DateTime.UtcNow;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
bars.Add(new TBar(time.AddSeconds(i), 100 + i, 110 + i, 90 + i, 105 + i, 1000));
|
||||
}
|
||||
|
||||
Assert.Equal(10, fired);
|
||||
Assert.True(kdj.IsHot);
|
||||
}
|
||||
|
||||
// ── Additional: J line properties ──────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void J_CanExceedHundred()
|
||||
{
|
||||
// J = 3K - 2D. When K > D significantly, J > 100
|
||||
var kdj = new Kdj(length: 3, signal: 3);
|
||||
DateTime time = DateTime.UtcNow;
|
||||
|
||||
// Sharp upward move should make K > D, and J can exceed 100
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
kdj.Update(new TBar(time.AddSeconds(i), 100, 105, 95, 100, 1000));
|
||||
}
|
||||
// Now sharp move up
|
||||
for (int i = 3; i < 8; i++)
|
||||
{
|
||||
kdj.Update(new TBar(time.AddSeconds(i), 100 + (i - 2) * 5, 110 + (i - 2) * 5, 95 + (i - 2) * 5, 110 + (i - 2) * 5, 1000));
|
||||
}
|
||||
|
||||
// J should be able to exceed 100 (it's unbounded)
|
||||
// This is a property test - we just verify J is computed as 3K-2D
|
||||
double expectedJ = 3.0 * kdj.K.Value - 2.0 * kdj.D.Value;
|
||||
Assert.Equal(expectedJ, kdj.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void J_CanGoNegative()
|
||||
{
|
||||
// J = 3K - 2D. When D > K significantly, J < 0
|
||||
var kdj = new Kdj(length: 3, signal: 3);
|
||||
DateTime time = DateTime.UtcNow;
|
||||
|
||||
// Start high
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
kdj.Update(new TBar(time.AddSeconds(i), 200, 210, 190, 210, 1000));
|
||||
}
|
||||
// Sharp move down
|
||||
for (int i = 3; i < 8; i++)
|
||||
{
|
||||
kdj.Update(new TBar(time.AddSeconds(i), 200 - (i - 2) * 5, 210 - (i - 2) * 5, 190 - (i - 2) * 5, 190 - (i - 2) * 5, 1000));
|
||||
}
|
||||
|
||||
double expectedJ = 3.0 * kdj.K.Value - 2.0 * kdj.D.Value;
|
||||
Assert.Equal(expectedJ, kdj.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void K_D_ClampedBetween0And100()
|
||||
{
|
||||
var kdj = new Kdj(length: 5, signal: 3);
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.3, seed: 99);
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
kdj.Update(gbm.Next(isNew: true), isNew: true);
|
||||
|
||||
Assert.True(kdj.K.Value >= 0.0 && kdj.K.Value <= 100.0,
|
||||
$"K={kdj.K.Value} out of [0,100] at bar {i}");
|
||||
Assert.True(kdj.D.Value >= 0.0 && kdj.D.Value <= 100.0,
|
||||
$"D={kdj.D.Value} out of [0,100] at bar {i}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prime_SetsCorrectState()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 55);
|
||||
var bars = new TBarSeries();
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
bars.Add(gbm.Next(isNew: true));
|
||||
}
|
||||
|
||||
// Prime from TBarSeries
|
||||
var kdj1 = new Kdj(length: 5, signal: 3);
|
||||
kdj1.Prime(bars);
|
||||
|
||||
// Manual streaming
|
||||
var kdj2 = new Kdj(length: 5, signal: 3);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
kdj2.Update(bars[i], isNew: true);
|
||||
}
|
||||
|
||||
Assert.Equal(kdj2.K.Value, kdj1.K.Value, 1e-10);
|
||||
Assert.Equal(kdj2.D.Value, kdj1.D.Value, 1e-10);
|
||||
Assert.Equal(kdj2.Last.Value, kdj1.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_EmptySource_ReturnsEmpty()
|
||||
{
|
||||
var bars = new TBarSeries();
|
||||
var (k, d, j) = Kdj.Batch(bars, 9, 3);
|
||||
|
||||
Assert.Empty(k);
|
||||
Assert.Empty(d);
|
||||
Assert.Empty(j);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Batch_NullSource_ReturnsEmpty()
|
||||
{
|
||||
var (k, d, j) = Kdj.Batch(null!, 9, 3);
|
||||
|
||||
Assert.Empty(k);
|
||||
Assert.Empty(d);
|
||||
Assert.Empty(j);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// KDJ validation tests — self-consistency across modes.
|
||||
/// KDJ uses Wilder's RMA smoothing (unlike standard Stochastic which uses SMA),
|
||||
/// so no direct external library comparison is available. Validation is performed
|
||||
/// via cross-mode consistency, mathematical identity checks, and boundary analysis.
|
||||
/// </summary>
|
||||
[SkipLocalsInit]
|
||||
public sealed class KdjValidationTests(ITestOutputHelper output) : IDisposable
|
||||
{
|
||||
private readonly GBM _gbm = new(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42);
|
||||
private bool _disposed;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(disposing: true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (!_disposed && disposing)
|
||||
{
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streaming vs Batch consistency — validates that the streaming Update() path
|
||||
/// produces identical results to the static Batch() path for all three outputs.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void StreamingVsBatch_AllThreeOutputs_Match()
|
||||
{
|
||||
const int length = 9;
|
||||
const int signal = 3;
|
||||
int barCount = 200;
|
||||
|
||||
var bars = new TBarSeries();
|
||||
var streamKdj = new Kdj(length, signal);
|
||||
|
||||
for (int i = 0; i < barCount; i++)
|
||||
{
|
||||
var bar = _gbm.Next(isNew: true);
|
||||
bars.Add(bar);
|
||||
streamKdj.Update(bar, isNew: true);
|
||||
}
|
||||
|
||||
var (bK, bD, bJ) = Kdj.Batch(bars, length, signal);
|
||||
|
||||
int mismatches = 0;
|
||||
for (int i = 0; i < barCount; i++)
|
||||
{
|
||||
double errK = Math.Abs(bK.Values[i] - GetStreamK(bars, i, length, signal));
|
||||
double errD = Math.Abs(bD.Values[i] - GetStreamD(bars, i, length, signal));
|
||||
double errJ = Math.Abs(bJ.Values[i] - GetStreamJ(bars, i, length, signal));
|
||||
|
||||
if (errK > 1e-10 || errD > 1e-10 || errJ > 1e-10)
|
||||
{
|
||||
mismatches++;
|
||||
}
|
||||
}
|
||||
|
||||
// Final values must match exactly
|
||||
Assert.Equal(streamKdj.K.Value, bK.Values[^1], 1e-10);
|
||||
Assert.Equal(streamKdj.D.Value, bD.Values[^1], 1e-10);
|
||||
Assert.Equal(streamKdj.Last.Value, bJ.Values[^1], 1e-10);
|
||||
|
||||
output.WriteLine($"Streaming vs Batch: {barCount} bars, {mismatches} mismatches (tolerance 1e-10)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Span batch vs TBarSeries batch — validates that the low-level span API
|
||||
/// produces identical results to the high-level TBarSeries batch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void SpanBatch_VsTBarSeriesBatch_Match()
|
||||
{
|
||||
const int length = 14;
|
||||
const int signal = 5;
|
||||
int barCount = 150;
|
||||
|
||||
var bars = new TBarSeries();
|
||||
for (int i = 0; i < barCount; i++)
|
||||
{
|
||||
bars.Add(_gbm.Next(isNew: true));
|
||||
}
|
||||
|
||||
var (tK, tD, tJ) = Kdj.Batch(bars, length, signal);
|
||||
|
||||
double[] kOut = new double[barCount];
|
||||
double[] dOut = new double[barCount];
|
||||
double[] jOut = new double[barCount];
|
||||
Kdj.Batch(bars.HighValues, bars.LowValues, bars.CloseValues,
|
||||
kOut, dOut, jOut, length, signal);
|
||||
|
||||
for (int i = 0; i < barCount; i++)
|
||||
{
|
||||
Assert.Equal(tK.Values[i], kOut[i], 1e-10);
|
||||
Assert.Equal(tD.Values[i], dOut[i], 1e-10);
|
||||
Assert.Equal(tJ.Values[i], jOut[i], 1e-10);
|
||||
}
|
||||
|
||||
output.WriteLine($"Span vs TBarSeries Batch: {barCount} bars, all match within 1e-10");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mathematical identity: J = 3K - 2D must hold for all bars.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void J_Equals_3K_Minus_2D_ForAllBars()
|
||||
{
|
||||
const int length = 9;
|
||||
const int signal = 3;
|
||||
int barCount = 200;
|
||||
|
||||
var bars = new TBarSeries();
|
||||
for (int i = 0; i < barCount; i++)
|
||||
{
|
||||
bars.Add(_gbm.Next(isNew: true));
|
||||
}
|
||||
|
||||
var (bK, bD, bJ) = Kdj.Batch(bars, length, signal);
|
||||
|
||||
for (int i = 0; i < barCount; i++)
|
||||
{
|
||||
double expectedJ = 3.0 * bK.Values[i] - 2.0 * bD.Values[i];
|
||||
Assert.Equal(expectedJ, bJ.Values[i], 1e-10);
|
||||
}
|
||||
|
||||
output.WriteLine($"J = 3K - 2D identity verified for {barCount} bars");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// K and D must remain in [0, 100] for all bars.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void K_D_BoundedInZeroToHundred()
|
||||
{
|
||||
const int length = 5;
|
||||
const int signal = 3;
|
||||
int barCount = 500;
|
||||
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.05, sigma: 0.3, seed: 99);
|
||||
var bars = new TBarSeries();
|
||||
for (int i = 0; i < barCount; i++)
|
||||
{
|
||||
bars.Add(gbm.Next(isNew: true));
|
||||
}
|
||||
|
||||
var (bK, bD, _) = Kdj.Batch(bars, length, signal);
|
||||
|
||||
for (int i = 0; i < barCount; i++)
|
||||
{
|
||||
Assert.True(bK.Values[i] >= 0.0 && bK.Values[i] <= 100.0,
|
||||
$"K[{i}] = {bK.Values[i]} out of [0,100]");
|
||||
Assert.True(bD.Values[i] >= 0.0 && bD.Values[i] <= 100.0,
|
||||
$"D[{i}] = {bD.Values[i]} out of [0,100]");
|
||||
}
|
||||
|
||||
output.WriteLine($"K/D bounded [0,100] verified for {barCount} bars");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parameter sensitivity: different length/signal values produce different results.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(5, 2)]
|
||||
[InlineData(9, 3)]
|
||||
[InlineData(14, 5)]
|
||||
[InlineData(21, 7)]
|
||||
public void DifferentParameters_ProduceDifferentResults(int length, int signal)
|
||||
{
|
||||
int barCount = 100;
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42);
|
||||
var bars = new TBarSeries();
|
||||
for (int i = 0; i < barCount; i++)
|
||||
{
|
||||
bars.Add(gbm.Next(isNew: true));
|
||||
}
|
||||
|
||||
var (k1, _, _) = Kdj.Batch(bars, length, signal);
|
||||
var (k2, _, _) = Kdj.Batch(bars, length + 1, signal);
|
||||
|
||||
// Different lengths should produce different K/D/J
|
||||
bool anyDifferent = false;
|
||||
for (int i = length + 1; i < barCount; i++)
|
||||
{
|
||||
if (Math.Abs(k1.Values[i] - k2.Values[i]) > 1e-10)
|
||||
{
|
||||
anyDifferent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(anyDifferent, $"length={length} vs {length + 1} should differ");
|
||||
output.WriteLine($"Parameter sensitivity verified: length={length}, signal={signal}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constant price produces RSV=50, K→50, D→50, J→50 after convergence.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ConstantPrice_ConvergesToFifty()
|
||||
{
|
||||
const int length = 9;
|
||||
const int signal = 3;
|
||||
int barCount = 100;
|
||||
|
||||
var bars = new TBarSeries();
|
||||
DateTime time = DateTime.UtcNow;
|
||||
for (int i = 0; i < barCount; i++)
|
||||
{
|
||||
bars.Add(new TBar(time.AddSeconds(i), 100, 100, 100, 100, 1000));
|
||||
}
|
||||
|
||||
var (bK, bD, bJ) = Kdj.Batch(bars, length, signal);
|
||||
|
||||
// After warmup, all should converge to 50.0
|
||||
Assert.Equal(50.0, bK.Values[^1], 1e-6);
|
||||
Assert.Equal(50.0, bD.Values[^1], 1e-6);
|
||||
Assert.Equal(50.0, bJ.Values[^1], 1e-6);
|
||||
|
||||
output.WriteLine("Constant price → K=D=J=50 verified");
|
||||
}
|
||||
|
||||
// ── Helper: replay streaming to get per-bar values ──
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static double GetStreamK(TBarSeries bars, int upTo, int length, int signal)
|
||||
{
|
||||
var kdj = new Kdj(length, signal);
|
||||
for (int i = 0; i <= upTo; i++)
|
||||
{
|
||||
kdj.Update(bars[i], isNew: true);
|
||||
}
|
||||
return kdj.K.Value;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static double GetStreamD(TBarSeries bars, int upTo, int length, int signal)
|
||||
{
|
||||
var kdj = new Kdj(length, signal);
|
||||
for (int i = 0; i <= upTo; i++)
|
||||
{
|
||||
kdj.Update(bars[i], isNew: true);
|
||||
}
|
||||
return kdj.D.Value;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static double GetStreamJ(TBarSeries bars, int upTo, int length, int signal)
|
||||
{
|
||||
var kdj = new Kdj(length, signal);
|
||||
for (int i = 0; i <= upTo; i++)
|
||||
{
|
||||
kdj.Update(bars[i], isNew: true);
|
||||
}
|
||||
return kdj.Last.Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// KDJ: Enhanced Stochastic Oscillator with K, D, J lines.
|
||||
/// RSV = 100 * (close - lowestLow) / (highestHigh - lowestLow),
|
||||
/// K = RMA(RSV, signal), D = RMA(K, signal), J = 3K - 2D.
|
||||
/// Streaming path uses monotonic deques for O(1) amortized highest/lowest;
|
||||
/// corrections (isNew=false) rebuild deques without allocations.
|
||||
/// </summary>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Kdj : ITValuePublisher
|
||||
{
|
||||
private readonly int _length;
|
||||
private readonly int _signal;
|
||||
private readonly double _alpha;
|
||||
private readonly double _decay;
|
||||
private readonly double[] _hBuf;
|
||||
private readonly double[] _lBuf;
|
||||
private readonly MonotonicDeque _maxDeque;
|
||||
private readonly MonotonicDeque _minDeque;
|
||||
|
||||
private int _count;
|
||||
private long _index;
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(
|
||||
double K, double D, double EK, double ED,
|
||||
bool WarmupK, bool WarmupD,
|
||||
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 >= _length;
|
||||
|
||||
public event TValuePublishedHandler? Pub;
|
||||
|
||||
public Kdj(int length = 9, int signal = 3)
|
||||
{
|
||||
if (length <= 0)
|
||||
{
|
||||
throw new ArgumentException("Length must be greater than 0", nameof(length));
|
||||
}
|
||||
if (signal <= 0)
|
||||
{
|
||||
throw new ArgumentException("Signal must be greater than 0", nameof(signal));
|
||||
}
|
||||
|
||||
_length = length;
|
||||
_signal = signal;
|
||||
_alpha = 1.0 / signal;
|
||||
_decay = 1.0 - _alpha;
|
||||
_hBuf = new double[_length];
|
||||
_lBuf = new double[_length];
|
||||
_maxDeque = new MonotonicDeque(_length);
|
||||
_minDeque = new MonotonicDeque(_length);
|
||||
_count = 0;
|
||||
_index = -1;
|
||||
_s = new State(0.0, 0.0, 1.0, 1.0, true, true, double.NaN, double.NaN, double.NaN);
|
||||
_ps = _s;
|
||||
|
||||
Name = $"Kdj({length},{signal})";
|
||||
WarmupPeriod = length + signal - 1;
|
||||
_barHandler = HandleBar;
|
||||
}
|
||||
|
||||
public Kdj(TBarSeries source, int length = 9, int signal = 3) : this(length, signal)
|
||||
{
|
||||
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 < _length)
|
||||
{
|
||||
_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 % _length);
|
||||
_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 rsv = range > 0.0 ? 100.0 * (close - lowest) / range : 50.0;
|
||||
|
||||
// RMA smoothing: K = alpha * RSV + decay * prevK
|
||||
s.K = Math.FusedMultiplyAdd(s.K, _decay, _alpha * rsv);
|
||||
// RMA smoothing: D = alpha * K + decay * prevD
|
||||
s.D = Math.FusedMultiplyAdd(s.D, _decay, _alpha * s.K);
|
||||
|
||||
// Exponential warmup compensator for K
|
||||
double resultK;
|
||||
if (s.WarmupK)
|
||||
{
|
||||
s.EK *= _decay;
|
||||
double cK = 1.0 / (1.0 - s.EK);
|
||||
resultK = Math.Clamp(cK * s.K, 0.0, 100.0);
|
||||
s.WarmupK = s.EK > 1e-10;
|
||||
}
|
||||
else
|
||||
{
|
||||
resultK = Math.Clamp(s.K, 0.0, 100.0);
|
||||
}
|
||||
|
||||
// Exponential warmup compensator for D
|
||||
double resultD;
|
||||
if (s.WarmupD)
|
||||
{
|
||||
s.ED *= _decay;
|
||||
double cD = 1.0 / (1.0 - s.ED);
|
||||
resultD = Math.Clamp(cD * s.D, 0.0, 100.0);
|
||||
s.WarmupD = s.ED > 1e-10;
|
||||
}
|
||||
else
|
||||
{
|
||||
resultD = Math.Clamp(s.D, 0.0, 100.0);
|
||||
}
|
||||
|
||||
// J = 3K - 2D (unbounded)
|
||||
double j = Math.FusedMultiplyAdd(3.0, resultK, -2.0 * resultD);
|
||||
|
||||
_s = s;
|
||||
|
||||
K = new TValue(input.Time, resultK);
|
||||
D = new TValue(input.Time, resultD);
|
||||
Last = new TValue(input.Time, j);
|
||||
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
public (TSeries K, TSeries D, TSeries J) Update(TBarSeries source)
|
||||
{
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return (new TSeries([], []), 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);
|
||||
var tJ = new List<long>(len);
|
||||
var vJ = new List<double>(len);
|
||||
|
||||
CollectionsMarshal.SetCount(tK, len);
|
||||
CollectionsMarshal.SetCount(vK, len);
|
||||
CollectionsMarshal.SetCount(tD, len);
|
||||
CollectionsMarshal.SetCount(vD, len);
|
||||
CollectionsMarshal.SetCount(tJ, len);
|
||||
CollectionsMarshal.SetCount(vJ, len);
|
||||
|
||||
var vKSpan = CollectionsMarshal.AsSpan(vK);
|
||||
var vDSpan = CollectionsMarshal.AsSpan(vD);
|
||||
var vJSpan = CollectionsMarshal.AsSpan(vJ);
|
||||
|
||||
Batch(source.HighValues, source.LowValues, source.CloseValues,
|
||||
vKSpan, vDSpan, vJSpan, _length, _signal);
|
||||
|
||||
var tSpan = CollectionsMarshal.AsSpan(tK);
|
||||
source.Times.CopyTo(tSpan);
|
||||
tSpan.CopyTo(CollectionsMarshal.AsSpan(tD));
|
||||
tSpan.CopyTo(CollectionsMarshal.AsSpan(tJ));
|
||||
|
||||
// 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, vJSpan[^1]);
|
||||
|
||||
return (new TSeries(tK, vK), new TSeries(tD, vD), new TSeries(tJ, vJ));
|
||||
}
|
||||
|
||||
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);
|
||||
_maxDeque.Reset();
|
||||
_minDeque.Reset();
|
||||
_count = 0;
|
||||
_index = -1;
|
||||
_s = new State(0.0, 0.0, 1.0, 1.0, true, true, double.NaN, double.NaN, double.NaN);
|
||||
_ps = _s;
|
||||
Last = default;
|
||||
K = default;
|
||||
D = default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch calculation using spans (zero allocation).
|
||||
/// </summary>
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Batch(
|
||||
ReadOnlySpan<double> high,
|
||||
ReadOnlySpan<double> low,
|
||||
ReadOnlySpan<double> close,
|
||||
Span<double> kOut,
|
||||
Span<double> dOut,
|
||||
Span<double> jOut,
|
||||
int length,
|
||||
int signal = 3)
|
||||
{
|
||||
if (length <= 0)
|
||||
{
|
||||
throw new ArgumentException("Length must be greater than 0", nameof(length));
|
||||
}
|
||||
if (signal <= 0)
|
||||
{
|
||||
throw new ArgumentException("Signal must be greater than 0", nameof(signal));
|
||||
}
|
||||
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));
|
||||
}
|
||||
if (jOut.Length < high.Length)
|
||||
{
|
||||
throw new ArgumentException("J output span must be at least as long as input", nameof(jOut));
|
||||
}
|
||||
|
||||
int len = high.Length;
|
||||
if (len == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double alpha = 1.0 / signal;
|
||||
double decay = 1.0 - alpha;
|
||||
|
||||
// Compute highest/lowest via monotonic deque spans
|
||||
const int StackallocThreshold = 256;
|
||||
double[]? rentedUpper = null;
|
||||
double[]? rentedLower = null;
|
||||
scoped Span<double> upperBuf;
|
||||
scoped Span<double> lowerBuf;
|
||||
|
||||
if (len <= StackallocThreshold)
|
||||
{
|
||||
upperBuf = stackalloc double[len];
|
||||
lowerBuf = stackalloc double[len];
|
||||
}
|
||||
else
|
||||
{
|
||||
rentedUpper = System.Buffers.ArrayPool<double>.Shared.Rent(len);
|
||||
rentedLower = System.Buffers.ArrayPool<double>.Shared.Rent(len);
|
||||
upperBuf = rentedUpper.AsSpan(0, len);
|
||||
lowerBuf = rentedLower.AsSpan(0, len);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Highest.Batch(high, upperBuf, length);
|
||||
Lowest.Batch(low, lowerBuf, length);
|
||||
|
||||
double k = 0.0;
|
||||
double d = 0.0;
|
||||
double eK = 1.0;
|
||||
double eD = 1.0;
|
||||
bool warmupK = true;
|
||||
bool warmupD = true;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
double range = upperBuf[i] - lowerBuf[i];
|
||||
double rsv = range > 0.0 ? 100.0 * (close[i] - lowerBuf[i]) / range : 50.0;
|
||||
|
||||
k = Math.FusedMultiplyAdd(k, decay, alpha * rsv);
|
||||
d = Math.FusedMultiplyAdd(d, decay, alpha * k);
|
||||
|
||||
double resultK;
|
||||
if (warmupK)
|
||||
{
|
||||
eK *= decay;
|
||||
double cK = 1.0 / (1.0 - eK);
|
||||
resultK = Math.Clamp(cK * k, 0.0, 100.0);
|
||||
warmupK = eK > 1e-10;
|
||||
}
|
||||
else
|
||||
{
|
||||
resultK = Math.Clamp(k, 0.0, 100.0);
|
||||
}
|
||||
|
||||
double resultD;
|
||||
if (warmupD)
|
||||
{
|
||||
eD *= decay;
|
||||
double cD = 1.0 / (1.0 - eD);
|
||||
resultD = Math.Clamp(cD * d, 0.0, 100.0);
|
||||
warmupD = eD > 1e-10;
|
||||
}
|
||||
else
|
||||
{
|
||||
resultD = Math.Clamp(d, 0.0, 100.0);
|
||||
}
|
||||
|
||||
kOut[i] = resultK;
|
||||
dOut[i] = resultD;
|
||||
jOut[i] = Math.FusedMultiplyAdd(3.0, resultK, -2.0 * resultD);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (rentedUpper != null)
|
||||
{
|
||||
System.Buffers.ArrayPool<double>.Shared.Return(rentedUpper);
|
||||
}
|
||||
if (rentedLower != null)
|
||||
{
|
||||
System.Buffers.ArrayPool<double>.Shared.Return(rentedLower);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static (TSeries K, TSeries D, TSeries J) Batch(TBarSeries source, int length = 9, int signal = 3)
|
||||
{
|
||||
if (source == null || source.Count == 0)
|
||||
{
|
||||
return (new TSeries([], []), 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);
|
||||
var tJ = new List<long>(len);
|
||||
var vJ = new List<double>(len);
|
||||
|
||||
CollectionsMarshal.SetCount(tK, len);
|
||||
CollectionsMarshal.SetCount(vK, len);
|
||||
CollectionsMarshal.SetCount(tD, len);
|
||||
CollectionsMarshal.SetCount(vD, len);
|
||||
CollectionsMarshal.SetCount(tJ, len);
|
||||
CollectionsMarshal.SetCount(vJ, len);
|
||||
|
||||
Batch(source.HighValues, source.LowValues, source.CloseValues,
|
||||
CollectionsMarshal.AsSpan(vK),
|
||||
CollectionsMarshal.AsSpan(vD),
|
||||
CollectionsMarshal.AsSpan(vJ),
|
||||
length, signal);
|
||||
|
||||
var tSpan = CollectionsMarshal.AsSpan(tK);
|
||||
source.Times.CopyTo(tSpan);
|
||||
tSpan.CopyTo(CollectionsMarshal.AsSpan(tD));
|
||||
tSpan.CopyTo(CollectionsMarshal.AsSpan(tJ));
|
||||
|
||||
return (new TSeries(tK, vK), new TSeries(tD, vD), new TSeries(tJ, vJ));
|
||||
}
|
||||
|
||||
public static ((TSeries K, TSeries D, TSeries J) Results, Kdj Indicator) Calculate(TBarSeries source, int length = 9, int signal = 3)
|
||||
{
|
||||
var indicator = new Kdj(length, signal);
|
||||
var results = indicator.Update(source);
|
||||
return (results, indicator);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
# KDJ: Enhanced Stochastic Oscillator
|
||||
|
||||
> "K leads, D confirms, J exaggerates — three perspectives on momentum."
|
||||
|
||||
KDJ is an enhanced Stochastic Oscillator popular in Asian markets. It extends the classic Stochastic by adding a J line that amplifies divergence between K and D, providing earlier reversal signals. Uses Wilder's RMA (Exponential Moving Average with `α = 1/signal`) instead of SMA for smoother K and D lines.
|
||||
|
||||
## Calculation
|
||||
|
||||
1. Compute highest high and lowest low over the lookback period using monotonic deques.
|
||||
2. Calculate the Raw Stochastic Value (RSV).
|
||||
3. Smooth RSV with RMA to get K; smooth K with RMA to get D.
|
||||
4. Compute J as the amplified divergence.
|
||||
|
||||
Formula:
|
||||
|
||||
```
|
||||
RSV = 100 × (Close - LowestLow) / (HighestHigh - LowestLow)
|
||||
K = RMA(RSV, signal) // α = 1/signal
|
||||
D = RMA(K, signal) // α = 1/signal
|
||||
J = 3K - 2D
|
||||
```
|
||||
|
||||
If the price range is zero, RSV defaults to `50.0` (neutral). K and D are clamped to `[0, 100]`. J is unbounded and can exceed 100 or go below 0.
|
||||
|
||||
Exponential warmup compensators ensure accurate K and D values from the first bar, avoiding the typical initialization bias of recursive filters.
|
||||
|
||||
## Interpretation
|
||||
|
||||
- **K > D** → bullish momentum (K crosses above D = buy signal)
|
||||
- **K < D** → bearish momentum (K crosses below D = sell signal)
|
||||
- **J > 100** → strongly overbought, potential reversal down
|
||||
- **J < 0** → strongly oversold, potential reversal up
|
||||
- **K > 80** → overbought zone
|
||||
- **K < 20** → oversold zone
|
||||
|
||||
## Parameters
|
||||
|
||||
| Name | Type | Default | Range | Description |
|
||||
| :--- | :--- | :------ | :---- | :---------- |
|
||||
| `length` | `int` | `9` | `>0` | Lookback period for highest high / lowest low. |
|
||||
| `signal` | `int` | `3` | `>0` | RMA smoothing period for K and D lines. |
|
||||
|
||||
## API
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class Kdj {
|
||||
+Name : string
|
||||
+WarmupPeriod : int
|
||||
+IsHot : bool
|
||||
+K : TValue
|
||||
+D : TValue
|
||||
+Last : TValue (J line)
|
||||
+Update(TBar input, bool isNew) TValue
|
||||
+Update(TBarSeries source) (TSeries K, TSeries D, TSeries J)
|
||||
+Prime(TBarSeries source) void
|
||||
+Reset() void
|
||||
+Batch(TBarSeries source, int length, int signal) (TSeries K, TSeries D, TSeries J)
|
||||
+Batch(ReadOnlySpan~double~ high, low, close, Span~double~ kOut, dOut, jOut, int length, int signal) void
|
||||
+Calculate(TBarSeries source, int length, int signal) ((TSeries K, TSeries D, TSeries J) Results, Kdj Indicator)
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Example
|
||||
|
||||
```csharp
|
||||
using QuanTAlib;
|
||||
|
||||
// Initialize
|
||||
var kdj = new Kdj(length: 9, signal: 3);
|
||||
|
||||
foreach (var bar in bars)
|
||||
{
|
||||
kdj.Update(bar, isNew: true);
|
||||
|
||||
if (kdj.IsHot)
|
||||
{
|
||||
Console.WriteLine($"{bar.Time}: K={kdj.K.Value:F2} D={kdj.D.Value:F2} J={kdj.Last.Value:F2}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Profile
|
||||
|
||||
| Metric | Score | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| **Throughput** | 9 | O(1) amortized via monotonic deques. |
|
||||
| **Allocations** | 0 | Zero allocations in hot path. |
|
||||
| **Complexity** | O(1) | Amortized constant time per update. |
|
||||
| **Accuracy** | 10 | Exact match with PineScript reference. Exponential warmup compensators. |
|
||||
| **Timeliness** | 8 | RMA smoothing provides faster response than SMA-based Stochastic. |
|
||||
| **Overshoot** | 7 | J line intentionally unbounded for early signals. |
|
||||
| **Smoothness** | 8 | Double RMA smoothing eliminates noise. |
|
||||
|
||||
## Validation
|
||||
|
||||
No direct TA-Lib/Tulip/Skender equivalent exists for KDJ with Wilder's RMA smoothing. Validation is performed against the PineScript reference and internal consistency checks:
|
||||
- Streaming vs Batch vs Span cross-mode consistency
|
||||
- Mathematical identity: J = 3K − 2D
|
||||
- K/D bounded in [0, 100]
|
||||
- Parameter sensitivity across multiple configurations
|
||||
|
||||
## Sources
|
||||
|
||||
- Chinese securities analysis (KDJ is a standard indicator on Chinese exchanges)
|
||||
- [PineScript reference](kdj.pine)
|
||||
Reference in New Issue
Block a user