adding missing validations

This commit is contained in:
Miha Kralj
2026-02-26 09:59:44 -08:00
parent 467a8c1cef
commit 9ab37c1200
231 changed files with 60015 additions and 302 deletions
+138
View File
@@ -0,0 +1,138 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public sealed class KstIndicatorTests
{
[Fact]
public void KstIndicator_Constructor_SetsDefaults()
{
var indicator = new KstIndicator();
Assert.Equal(10, indicator.R1);
Assert.Equal(15, indicator.R2);
Assert.Equal(20, indicator.R3);
Assert.Equal(30, indicator.R4);
Assert.Equal(10, indicator.S1);
Assert.Equal(10, indicator.S2);
Assert.Equal(10, indicator.S3);
Assert.Equal(15, indicator.S4);
Assert.Equal(9, indicator.SignalPeriod);
Assert.True(indicator.ShowColdValues);
Assert.Equal("KST - Know Sure Thing Oscillator", indicator.Name);
Assert.True(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void KstIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new KstIndicator();
Assert.Equal(0, KstIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void KstIndicator_ShortName_IncludesParameters()
{
var indicator = new KstIndicator { R1 = 10, R2 = 15, R3 = 20, R4 = 30 };
indicator.Initialize();
Assert.Contains("KST", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("10", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void KstIndicator_SourceCodeLink_IsValid()
{
var indicator = new KstIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Kst", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void KstIndicator_Initialize_CreatesTwoLineSeries()
{
var indicator = new KstIndicator { R1 = 5, R2 = 7, R3 = 9, R4 = 11 };
indicator.Initialize();
// KST line + Signal line
Assert.Equal(2, indicator.LinesSeries.Count);
}
[Fact]
public void KstIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new KstIndicator { R1 = 3, R2 = 4, R3 = 5, R4 = 6, S1 = 2, S2 = 2, S3 = 2, S4 = 2, SignalPeriod = 2 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 30; 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 kst = indicator.LinesSeries[0].GetValue(0);
double sig = indicator.LinesSeries[1].GetValue(0);
Assert.True(double.IsFinite(kst));
Assert.True(double.IsFinite(sig));
}
[Fact]
public void KstIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new KstIndicator { R1 = 3, R2 = 4, R3 = 5, R4 = 6, S1 = 2, S2 = 2, S3 = 2, S4 = 2, SignalPeriod = 2 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 15; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
indicator.HistoricalData.AddBar(now.AddMinutes(15), 115, 125, 105, 120);
var newArgs = new UpdateArgs(UpdateReason.NewBar);
indicator.ProcessUpdate(newArgs);
double kst = indicator.LinesSeries[0].GetValue(0);
double sig = indicator.LinesSeries[1].GetValue(0);
Assert.True(double.IsFinite(kst));
Assert.True(double.IsFinite(sig));
}
[Fact]
public void KstIndicator_DifferentSourceTypes_ProcessCorrectly()
{
foreach (var sourceType in new[] { SourceType.Open, SourceType.High, SourceType.Low, SourceType.Close })
{
var indicator = new KstIndicator
{
R1 = 3, R2 = 4, R3 = 5, R4 = 6,
S1 = 2, S2 = 2, S3 = 2, S4 = 2,
SignalPeriod = 2,
Source = sourceType
};
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i * 0.5, 110 + i * 0.5, 90 + i * 0.5, 105 + i * 0.5);
indicator.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
}
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
Assert.True(double.IsFinite(indicator.LinesSeries[1].GetValue(0)));
}
}
}
+86
View File
@@ -0,0 +1,86 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class KstIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("ROC Period 1", sortIndex: 1, 1, 500, 1, 0)]
public int R1 { get; set; } = 10;
[InputParameter("ROC Period 2", sortIndex: 2, 1, 500, 1, 0)]
public int R2 { get; set; } = 15;
[InputParameter("ROC Period 3", sortIndex: 3, 1, 500, 1, 0)]
public int R3 { get; set; } = 20;
[InputParameter("ROC Period 4", sortIndex: 4, 1, 500, 1, 0)]
public int R4 { get; set; } = 30;
[InputParameter("SMA Smooth 1", sortIndex: 5, 1, 500, 1, 0)]
public int S1 { get; set; } = 10;
[InputParameter("SMA Smooth 2", sortIndex: 6, 1, 500, 1, 0)]
public int S2 { get; set; } = 10;
[InputParameter("SMA Smooth 3", sortIndex: 7, 1, 500, 1, 0)]
public int S3 { get; set; } = 10;
[InputParameter("SMA Smooth 4", sortIndex: 8, 1, 500, 1, 0)]
public int S4 { get; set; } = 15;
[InputParameter("Signal Period", sortIndex: 9, 1, 500, 1, 0)]
public int SignalPeriod { get; set; } = 9;
[IndicatorExtensions.DataSourceInput(sortIndex: 10)]
public SourceType Source { get; set; } = SourceType.Close;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Kst _kst = null!;
private readonly LineSeries _kstSeries;
private readonly LineSeries _signalSeries;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"KST ({R1},{R2},{R3},{R4})";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/kst/Kst.Quantower.cs";
public KstIndicator()
{
OnBackGround = true;
SeparateWindow = true;
Name = "KST - Know Sure Thing Oscillator";
Description = "Weighted sum of 4 smoothed ROC values with signal line (SMA of KST)";
_kstSeries = new LineSeries(name: "KST", color: Color.Yellow, width: 2, style: LineStyle.Solid);
_signalSeries = new LineSeries(name: "Signal", color: Color.Aqua, width: 1, style: LineStyle.Solid);
AddLineSeries(_kstSeries);
AddLineSeries(_signalSeries);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_kst = new Kst(R1, R2, R3, R4, S1, S2, S3, S4, SignalPeriod);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
var priceSelector = Source.GetPriceSelector();
var item = HistoricalData[0, SeekOriginHistory.End];
double price = priceSelector(item);
_ = _kst.Update(new TValue(item.TimeLeft, price), args.IsNewBar());
_kstSeries.SetValue(_kst.KstValue.Value, _kst.IsHot, ShowColdValues);
_signalSeries.SetValue(_kst.Signal.Value, _kst.IsHot, ShowColdValues);
}
}
+517
View File
@@ -0,0 +1,517 @@
using Xunit;
namespace QuanTAlib.Tests;
// ── A) Constructor Validation ────────────────────────────────────────────────
public sealed class KstConstructorTests
{
[Fact]
public void Constructor_ZeroR1_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Kst(r1: 0));
Assert.Equal("r1", ex.ParamName);
}
[Fact]
public void Constructor_NegativeR2_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Kst(r2: -1));
Assert.Equal("r2", ex.ParamName);
}
[Fact]
public void Constructor_ZeroR3_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Kst(r3: 0));
Assert.Equal("r3", ex.ParamName);
}
[Fact]
public void Constructor_ZeroR4_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Kst(r4: 0));
Assert.Equal("r4", ex.ParamName);
}
[Fact]
public void Constructor_ZeroS1_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Kst(s1: 0));
Assert.Equal("s1", ex.ParamName);
}
[Fact]
public void Constructor_ZeroS4_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Kst(s4: 0));
Assert.Equal("s4", ex.ParamName);
}
[Fact]
public void Constructor_ZeroSigPeriod_ThrowsArgumentException()
{
var ex = Assert.Throws<ArgumentException>(() => new Kst(sigPeriod: 0));
Assert.Equal("sigPeriod", ex.ParamName);
}
[Fact]
public void Constructor_Defaults_Creates()
{
var kst = new Kst();
Assert.NotNull(kst);
Assert.Contains("Kst", kst.Name, StringComparison.Ordinal);
}
[Fact]
public void Constructor_WarmupPeriod_IsPositive()
{
var kst = new Kst();
Assert.True(kst.WarmupPeriod > 0);
}
[Fact]
public void Constructor_CustomParams_NameReflectsThem()
{
var kst = new Kst(r1: 5, r2: 8, r3: 10, r4: 15, s1: 3, s2: 3, s3: 3, s4: 5, sigPeriod: 4);
Assert.Contains("5", kst.Name, StringComparison.Ordinal);
Assert.Contains("4", kst.Name, StringComparison.Ordinal);
}
}
// ── B) Basic Calculation ─────────────────────────────────────────────────────
public sealed class KstBasicTests
{
[Fact]
public void BasicCalculation_DoesNotCrash()
{
var kst = new Kst();
var result = kst.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.Equal(result.Value, kst.Last.Value);
}
[Fact]
public void FirstBar_OutputIsFinite()
{
var kst = new Kst();
var result = kst.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(double.IsFinite(result.Value));
}
[Fact]
public void Signal_IsFiniteAfterFirstBar()
{
var kst = new Kst();
kst.Update(new TValue(DateTime.UtcNow, 100.0));
Assert.True(double.IsFinite(kst.Signal.Value));
}
[Fact]
public void Name_Available()
{
var kst = new Kst();
Assert.False(string.IsNullOrEmpty(kst.Name));
}
[Fact]
public void Last_IsAccessible()
{
var kst = new Kst(r1: 3, r2: 5, r3: 7, r4: 9, s1: 3, s2: 3, s3: 3, s4: 3, sigPeriod: 3);
for (int i = 0; i < 20; i++)
{
kst.Update(new TValue(DateTime.UtcNow, 100.0 + i));
}
Assert.True(double.IsFinite(kst.Last.Value));
Assert.True(double.IsFinite(kst.KstValue.Value));
Assert.True(double.IsFinite(kst.Signal.Value));
}
[Fact]
public void ConstantPrice_KstIsZero()
{
// All ROC = 0 → KST = 0
var kst = new Kst(r1: 2, r2: 3, r3: 4, r4: 5, s1: 2, s2: 2, s3: 2, s4: 2, sigPeriod: 2);
for (int i = 0; i < 20; i++)
{
kst.Update(new TValue(DateTime.UtcNow, 100.0));
}
Assert.Equal(0.0, kst.KstValue.Value, 1e-10);
Assert.Equal(0.0, kst.Signal.Value, 1e-10);
}
}
// ── C) State + Bar Correction ────────────────────────────────────────────────
public sealed class KstBarCorrectionTests
{
[Fact]
public void IsNew_True_AdvancesState()
{
var kst = new Kst(r1: 3, r2: 4, r3: 5, r4: 6, s1: 2, s2: 2, s3: 2, s4: 2, sigPeriod: 2);
for (int i = 0; i < 5; i++)
{
kst.Update(new TValue(DateTime.UtcNow, 100.0 + i * 2), isNew: true);
}
double val1 = kst.Last.Value;
kst.Update(new TValue(DateTime.UtcNow, 110.0), isNew: true);
double val2 = kst.Last.Value;
Assert.True(double.IsFinite(val1));
Assert.True(double.IsFinite(val2));
}
[Fact]
public void IsNew_False_Rollback()
{
var kst = new Kst(r1: 3, r2: 4, r3: 5, r4: 6, s1: 2, s2: 2, s3: 2, s4: 2, sigPeriod: 2);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 42);
for (int i = 0; i < 10; i++)
{
var bar = gbm.Next(isNew: true);
kst.Update(new TValue(bar.Time, bar.Close), isNew: true);
}
var nextBar = gbm.Next(isNew: true);
var originalInput = new TValue(nextBar.Time, nextBar.Close);
var val1 = kst.Update(originalInput, isNew: true);
// Overwrite with different value
kst.Update(new TValue(nextBar.Time, nextBar.Close + 50), isNew: false);
// Restore original → must match
var restored = kst.Update(originalInput, isNew: false);
Assert.Equal(val1.Value, restored.Value, 1e-10);
}
[Fact]
public void IterativeCorrections_RestoreToOriginalState()
{
var kst = new Kst(r1: 3, r2: 4, r3: 5, r4: 6, s1: 2, s2: 2, s3: 2, s4: 2, sigPeriod: 2);
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1);
TValue twentyInput = default;
for (int i = 0; i < 20; i++)
{
var bar = gbm.Next(isNew: true);
twentyInput = new TValue(bar.Time, bar.Close);
kst.Update(twentyInput, isNew: true);
}
double stateAfterTwenty = kst.Last.Value;
for (int i = 0; i < 9; i++)
{
var bar = gbm.Next(isNew: false);
kst.Update(new TValue(bar.Time, bar.Close), isNew: false);
}
var finalResult = kst.Update(twentyInput, isNew: false);
Assert.Equal(stateAfterTwenty, finalResult.Value, 1e-10);
}
[Fact]
public void Reset_ClearsState()
{
var kst = new Kst(r1: 3, r2: 4, r3: 5, r4: 6, s1: 2, s2: 2, s3: 2, s4: 2, sigPeriod: 2);
for (int i = 0; i < 20; i++)
{
kst.Update(new TValue(DateTime.UtcNow, 100.0 + i));
}
kst.Reset();
Assert.False(kst.IsHot);
Assert.Equal(0.0, kst.Last.Value);
}
}
// ── D) Warmup / Convergence ──────────────────────────────────────────────────
public sealed class KstWarmupTests
{
[Fact]
public void IsHot_InitiallyFalse()
{
var kst = new Kst();
Assert.False(kst.IsHot);
}
[Fact]
public void IsHot_BecomesTrueAfterWarmupPeriodBars()
{
var kst = new Kst(r1: 3, r2: 4, r3: 5, r4: 6, s1: 2, s2: 2, s3: 2, s4: 2, sigPeriod: 2);
int warmup = kst.WarmupPeriod;
for (int i = 1; i < warmup; i++)
{
kst.Update(new TValue(DateTime.UtcNow, 100.0 + i));
Assert.False(kst.IsHot, $"Should not be hot at bar {i} (need {warmup})");
}
kst.Update(new TValue(DateTime.UtcNow, 100.0 + warmup));
Assert.True(kst.IsHot);
}
[Fact]
public void WarmupPeriod_DependsOnParameters()
{
var kst1 = new Kst(r1: 3, r2: 4, r3: 5, r4: 6, s1: 2, s2: 2, s3: 2, s4: 2, sigPeriod: 2);
var kst2 = new Kst(r1: 5, r2: 8, r3: 10, r4: 15, s1: 5, s2: 5, s3: 5, s4: 5, sigPeriod: 5);
Assert.True(kst2.WarmupPeriod > kst1.WarmupPeriod);
}
}
// ── E) Robustness ────────────────────────────────────────────────────────────
public sealed class KstRobustnessTests
{
[Fact]
public void NaN_UsesLastValidValue()
{
var kst = new Kst(r1: 2, r2: 3, r3: 4, r4: 5, s1: 2, s2: 2, s3: 2, s4: 2, sigPeriod: 2);
for (int i = 0; i < 10; i++)
{
kst.Update(new TValue(DateTime.UtcNow, 100.0 + i));
}
kst.Update(new TValue(DateTime.UtcNow, double.NaN));
Assert.True(double.IsFinite(kst.Last.Value), "NaN input should not produce NaN output");
}
[Fact]
public void PositiveInfinity_UsesLastValidValue()
{
var kst = new Kst(r1: 2, r2: 3, r3: 4, r4: 5, s1: 2, s2: 2, s3: 2, s4: 2, sigPeriod: 2);
for (int i = 0; i < 10; i++)
{
kst.Update(new TValue(DateTime.UtcNow, 100.0 + i));
}
kst.Update(new TValue(DateTime.UtcNow, double.PositiveInfinity));
Assert.True(double.IsFinite(kst.Last.Value));
}
[Fact]
public void BatchNaN_SafeOutput()
{
var kst = new Kst(r1: 2, r2: 3, r3: 4, r4: 5, s1: 2, s2: 2, s3: 2, s4: 2, sigPeriod: 2);
kst.Update(new TValue(DateTime.UtcNow, 100.0));
for (int i = 0; i < 5; i++)
{
kst.Update(new TValue(DateTime.UtcNow, double.NaN));
}
kst.Update(new TValue(DateTime.UtcNow, 110.0));
Assert.True(double.IsFinite(kst.Last.Value));
}
}
// ── F) Consistency (all 4 API modes agree) ───────────────────────────────────
public sealed class KstConsistencyTests
{
private static TSeries MakeSeries(double[] vals)
{
var times = new List<long>(vals.Length);
var values = new List<double>(vals.Length);
var t0 = DateTime.UtcNow;
for (int i = 0; i < vals.Length; i++)
{
times.Add(t0.AddSeconds(i).Ticks);
values.Add(vals[i]);
}
return new TSeries(times, values);
}
[Fact]
public void Streaming_Equals_Batch_TSeries()
{
int r1 = 3, r2 = 4, r3 = 5, r4 = 6, s1 = 2, s2 = 2, s3 = 2, s4 = 2, sig = 2;
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 7);
int count = 50;
var prices = new double[count];
for (int i = 0; i < count; i++) { prices[i] = gbm.Next(isNew: true).Close; }
// Streaming
var kstStream = new Kst(r1, r2, r3, r4, s1, s2, s3, s4, sig);
var streamK = new double[count];
var streamS = new double[count];
for (int i = 0; i < count; i++)
{
kstStream.Update(new TValue(DateTime.UtcNow.AddSeconds(i), prices[i]));
streamK[i] = kstStream.KstValue.Value;
streamS[i] = kstStream.Signal.Value;
}
// Batch TSeries
var series = MakeSeries(prices);
var kstBatch = new Kst(r1, r2, r3, r4, s1, s2, s3, s4, sig);
var (batchK, batchSig) = kstBatch.Update(series);
for (int i = 0; i < count; i++)
{
Assert.Equal(streamK[i], batchK.Values[i], 1e-9);
Assert.Equal(streamS[i], batchSig.Values[i], 1e-9);
}
}
[Fact]
public void Span_Equals_Streaming()
{
int r1 = 3, r2 = 4, r3 = 5, r4 = 6, s1 = 2, s2 = 2, s3 = 2, s4 = 2, sig = 2;
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 11);
int count = 60;
var prices = new double[count];
for (int i = 0; i < count; i++) { prices[i] = gbm.Next(isNew: true).Close; }
// Span Batch
var spanK = new double[count];
var spanS = new double[count];
Kst.Batch(prices, spanK, spanS, r1, r2, r3, r4, s1, s2, s3, s4, sig);
// Streaming
var kstStream = new Kst(r1, r2, r3, r4, s1, s2, s3, s4, sig);
for (int i = 0; i < count; i++)
{
kstStream.Update(new TValue(DateTime.UtcNow.AddSeconds(i), prices[i]));
Assert.Equal(spanK[i], kstStream.KstValue.Value, 1e-9);
Assert.Equal(spanS[i], kstStream.Signal.Value, 1e-9);
}
}
[Fact]
public void Eventing_Equals_Manual_Streaming()
{
int r1 = 3, r2 = 4, r3 = 5, r4 = 6, s1 = 2, s2 = 2, s3 = 2, s4 = 2, sig = 2;
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 13);
var series = new TSeries();
// Subscribe BEFORE adding data so Pub events fire into kstEvent
var kstEvent = new Kst(series, r1, r2, r3, r4, s1, s2, s3, s4, sig);
for (int i = 0; i < 30; i++)
{
var bar = gbm.Next(isNew: true);
series.Add(new TValue(bar.Time, bar.Close), isNew: true);
}
double eventLast = kstEvent.Last.Value;
// Manual streaming (replay same data)
var kstManual = new Kst(r1, r2, r3, r4, s1, s2, s3, s4, sig);
foreach (var tv in series)
{
kstManual.Update(tv, isNew: true);
}
Assert.Equal(eventLast, kstManual.Last.Value, 1e-9);
}
}
// ── G) Span API Tests ────────────────────────────────────────────────────────
public sealed class KstSpanTests
{
[Fact]
public void Span_MismatchedOutputLength_ThrowsArgumentException()
{
double[] src = [1, 2, 3, 4, 5];
double[] kstOut = new double[5];
double[] sigOut = new double[4]; // wrong length
var ex = Assert.Throws<ArgumentException>(() =>
Kst.Batch(src, kstOut, sigOut));
Assert.Equal("sigOut", ex.ParamName);
}
[Fact]
public void Span_MismatchedKstOutputLength_ThrowsArgumentException()
{
double[] src = [1, 2, 3, 4, 5];
double[] kstOut = new double[4]; // wrong length
double[] sigOut = new double[5];
var ex = Assert.Throws<ArgumentException>(() =>
Kst.Batch(src, kstOut, sigOut));
Assert.Equal("kstOut", ex.ParamName);
}
[Fact]
public void Span_ZeroR1_ThrowsArgumentException()
{
double[] src = [1, 2, 3];
double[] k = new double[3];
double[] s = new double[3];
var ex = Assert.Throws<ArgumentException>(() =>
Kst.Batch(src, k, s, r1: 0));
Assert.Equal("r1", ex.ParamName);
}
[Fact]
public void Span_EmptyInput_NoException()
{
double[] src = [];
double[] k = [];
double[] s = [];
Kst.Batch(src, k, s); // should not throw
Assert.Empty(src);
}
[Fact]
public void Span_NaNInput_SafeOutput()
{
var prices = new double[50];
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 99);
for (int i = 0; i < 50; i++) { prices[i] = gbm.Next(isNew: true).Close; }
prices[10] = double.NaN;
prices[20] = double.PositiveInfinity;
var kOut = new double[50];
var sOut = new double[50];
Kst.Batch(prices, kOut, sOut, r1: 3, r2: 4, r3: 5, r4: 6, s1: 2, s2: 2, s3: 2, s4: 2, sigPeriod: 2);
foreach (var v in kOut) { Assert.True(double.IsFinite(v)); }
foreach (var v in sOut) { Assert.True(double.IsFinite(v)); }
}
[Fact]
public void Span_LargeInput_NoStackOverflow()
{
int n = 5000;
var prices = new double[n];
var gbm = new GBM(startPrice: 100.0, mu: 0.01, sigma: 0.1, seed: 77);
for (int i = 0; i < n; i++) { prices[i] = gbm.Next(isNew: true).Close; }
var kOut = new double[n];
var sOut = new double[n];
Kst.Batch(prices, kOut, sOut); // default periods, large array
Assert.True(double.IsFinite(kOut[^1]));
}
}
// ── H) Chainability ──────────────────────────────────────────────────────────
public sealed class KstChainabilityTests
{
[Fact]
public void Pub_Fires_OnUpdate()
{
var kst = new Kst(r1: 2, r2: 3, r3: 4, r4: 5, s1: 2, s2: 2, s3: 2, s4: 2, sigPeriod: 2);
int fireCount = 0;
kst.Pub += (object? _, in TValueEventArgs _e) => fireCount++;
for (int i = 0; i < 5; i++)
{
kst.Update(new TValue(DateTime.UtcNow, 100.0 + i));
}
Assert.Equal(5, fireCount);
}
[Fact]
public void EventBasedChaining_WorksCorrectly()
{
var series = new TSeries();
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.1, seed: 5);
var kst = new Kst(series,
r1: 2, r2: 3, r3: 4, r4: 5,
s1: 2, s2: 2, s3: 2, s4: 2,
sigPeriod: 2);
for (int i = 0; i < 20; i++)
{
var bar = gbm.Next(isNew: true);
series.Add(new TValue(bar.Time, bar.Close), isNew: true);
}
Assert.True(double.IsFinite(kst.Last.Value));
Assert.True(double.IsFinite(kst.Signal.Value));
}
}
+237
View File
@@ -0,0 +1,237 @@
using Xunit;
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
/// <summary>
/// KST Validation Tests.
/// No external library (TA-Lib, Skender, Tulip, Ooples) implements KST with
/// the Pring default parameters (r=10/15/20/30, s=10/10/10/15), so we use
/// self-consistency checks: batch==streaming==span, directional correctness,
/// and component identity verification.
/// </summary>
public sealed class KstValidationTests(ITestOutputHelper output)
{
private readonly ITestOutputHelper _output = output;
private static double[] GeneratePrices(int count, int seed = 42)
{
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: seed);
var prices = new double[count];
for (int i = 0; i < count; i++) { prices[i] = gbm.Next(isNew: true).Close; }
return prices;
}
private static TSeries MakeSeries(double[] vals)
{
var times = new List<long>(vals.Length);
var values = new List<double>(vals.Length);
var t0 = DateTime.UtcNow;
for (int i = 0; i < vals.Length; i++)
{
times.Add(t0.AddSeconds(i).Ticks);
values.Add(vals[i]);
}
return new TSeries(times, values);
}
// ── A) Streaming == Batch(TSeries) ────────────────────────────────────────
[Fact]
public void Validate_Streaming_Equals_Batch()
{
int[] r = [3, 5, 7, 9];
int[] s = [2, 2, 2, 3];
int sig = 2;
double[] prices = GeneratePrices(200);
// Streaming
var kstStream = new Kst(r[0], r[1], r[2], r[3], s[0], s[1], s[2], s[3], sig);
var streamK = new double[prices.Length];
var streamS = new double[prices.Length];
for (int i = 0; i < prices.Length; i++)
{
kstStream.Update(new TValue(DateTime.UtcNow.AddSeconds(i), prices[i]));
streamK[i] = kstStream.KstValue.Value;
streamS[i] = kstStream.Signal.Value;
}
// Batch TSeries
var series = MakeSeries(prices);
var kstBatch = new Kst(r[0], r[1], r[2], r[3], s[0], s[1], s[2], s[3], sig);
var (bK, bS) = kstBatch.Update(series);
for (int i = 0; i < prices.Length; i++)
{
Assert.Equal(streamK[i], bK.Values[i], 1e-6);
Assert.Equal(streamS[i], bS.Values[i], 1e-6);
}
_output.WriteLine("KST Streaming == Batch(TSeries): PASSED");
}
// ── B) Batch(TSeries) == Span ─────────────────────────────────────────────
[Fact]
public void Validate_Batch_Equals_Span()
{
int r1 = 3, r2 = 5, r3 = 7, r4 = 9, s1 = 2, s2 = 2, s3 = 2, s4 = 3, sig = 2;
double[] prices = GeneratePrices(200, seed: 77);
// Span
var spanK = new double[prices.Length];
var spanS = new double[prices.Length];
Kst.Batch(prices, spanK, spanS, r1, r2, r3, r4, s1, s2, s3, s4, sig);
// Batch TSeries
var series = MakeSeries(prices);
var (bK, bS) = Kst.Batch(series, r1, r2, r3, r4, s1, s2, s3, s4, sig);
for (int i = 0; i < prices.Length; i++)
{
Assert.Equal(spanK[i], bK.Values[i], 1e-9);
Assert.Equal(spanS[i], bS.Values[i], 1e-9);
}
_output.WriteLine("KST Batch(TSeries) == Span: PASSED");
}
// ── C) Rising prices → positive ROC → positive KST ────────────────────────
[Fact]
public void Validate_StrictlyRising_KstPositive()
{
double startPrice = 100.0;
int n = 60;
double[] prices = new double[n];
for (int i = 0; i < n; i++) { prices[i] = startPrice + i * 0.5; } // constant rise
var spanK = new double[n];
var spanS = new double[n];
Kst.Batch(prices, spanK, spanS, r1: 5, r2: 7, r3: 9, r4: 11, s1: 3, s2: 3, s3: 3, s4: 3, sigPeriod: 3);
// Once warmed up the KST should be positive (all ROC > 0)
int warmup = new Kst(5, 7, 9, 11, 3, 3, 3, 3, 3).WarmupPeriod;
for (int i = warmup; i < n; i++)
{
Assert.True(spanK[i] > 0, $"KST should be positive at index {i}, got {spanK[i]}");
}
_output.WriteLine("KST directional correctness (rising price → positive KST): PASSED");
}
// ── D) Falling prices → negative KST ─────────────────────────────────────
[Fact]
public void Validate_StrictlyFalling_KstNegative()
{
double startPrice = 200.0;
int n = 60;
double[] prices = new double[n];
for (int i = 0; i < n; i++) { prices[i] = startPrice - i * 0.5; } // constant fall
var spanK = new double[n];
var spanS = new double[n];
Kst.Batch(prices, spanK, spanS, r1: 5, r2: 7, r3: 9, r4: 11, s1: 3, s2: 3, s3: 3, s4: 3, sigPeriod: 3);
int warmup = new Kst(5, 7, 9, 11, 3, 3, 3, 3, 3).WarmupPeriod;
for (int i = warmup; i < n; i++)
{
Assert.True(spanK[i] < 0, $"KST should be negative at index {i}, got {spanK[i]}");
}
_output.WriteLine("KST directional correctness (falling price → negative KST): PASSED");
}
// ── E) Constant price → KST = 0 and Signal = 0 ───────────────────────────
[Fact]
public void Validate_ConstantPrice_KstZero()
{
int n = 80;
double[] prices = new double[n];
Array.Fill(prices, 100.0);
var spanK = new double[n];
var spanS = new double[n];
Kst.Batch(prices, spanK, spanS, r1: 5, r2: 7, r3: 9, r4: 11, s1: 3, s2: 3, s3: 3, s4: 3, sigPeriod: 3);
// All ROC = 0, so KST = 0 and Signal = 0
for (int i = 0; i < n; i++)
{
Assert.Equal(0.0, spanK[i], 1e-10);
Assert.Equal(0.0, spanS[i], 1e-10);
}
_output.WriteLine("KST constant price → KST=0, Signal=0: PASSED");
}
// ── F) Default parameters (Pring spec) produce finite values ─────────────
[Fact]
public void Validate_DefaultParameters_FiniteOutput()
{
double[] prices = GeneratePrices(500, seed: 123);
var spanK = new double[prices.Length];
var spanS = new double[prices.Length];
Kst.Batch(prices, spanK, spanS); // all defaults
int warmup = new Kst().WarmupPeriod;
for (int i = warmup; i < prices.Length; i++)
{
Assert.True(double.IsFinite(spanK[i]), $"KST[{i}] not finite: {spanK[i]}");
Assert.True(double.IsFinite(spanS[i]), $"Signal[{i}] not finite: {spanS[i]}");
}
_output.WriteLine($"KST default parameters (warmup={warmup}), 500 bars: all finite. PASSED");
}
// ── G) Signal lags KST (SMA smoothing effect) ────────────────────────────
[Fact]
public void Validate_Signal_LooksLikeSmoothedKst()
{
// A sharp rise then fall in KST leaves signal trailing behind
int r1 = 3, r2 = 4, r3 = 5, r4 = 6, s1 = 2, s2 = 2, s3 = 2, s4 = 2, sigPeriod = 4;
double[] prices = GeneratePrices(80, seed: 55);
var spanK = new double[prices.Length];
var spanS = new double[prices.Length];
Kst.Batch(prices, spanK, spanS, r1, r2, r3, r4, s1, s2, s3, s4, sigPeriod);
// Signal should not be identical to KST (it is a smoothed version)
int warmup = new Kst(r1, r2, r3, r4, s1, s2, s3, s4, sigPeriod).WarmupPeriod;
bool anyDifferent = false;
for (int i = warmup; i < prices.Length; i++)
{
if (Math.Abs(spanK[i] - spanS[i]) > 1e-10)
{
anyDifferent = true;
break;
}
}
Assert.True(anyDifferent, "Signal should differ from KST (it is a smoothed version)");
_output.WriteLine("KST Signal ≠ KST (smoothing effect verified): PASSED");
}
// ── H) Multiple parameter sets produce distinct results ───────────────────
[Fact]
public void Validate_DifferentParams_ProduceDifferentResults()
{
double[] prices = GeneratePrices(100, seed: 88);
var k1 = new double[prices.Length]; var s1a = new double[prices.Length];
var k2 = new double[prices.Length]; var s2a = new double[prices.Length];
Kst.Batch(prices, k1, s1a, r1: 3, r2: 4, r3: 5, r4: 6, s1: 2, s2: 2, s3: 2, s4: 2, sigPeriod: 2);
Kst.Batch(prices, k2, s2a, r1: 5, r2: 8, r3: 11, r4: 14, s1: 4, s2: 4, s3: 4, s4: 4, sigPeriod: 4);
int warmup = Math.Max(
new Kst(3, 4, 5, 6, 2, 2, 2, 2, 2).WarmupPeriod,
new Kst(5, 8, 11, 14, 4, 4, 4, 4, 4).WarmupPeriod);
bool anyDifferent = false;
for (int i = warmup; i < prices.Length; i++)
{
if (Math.Abs(k1[i] - k2[i]) > 1e-6) { anyDifferent = true; break; }
}
Assert.True(anyDifferent, "Different parameters should produce different KST values");
_output.WriteLine("KST different parameters → different results: PASSED");
}
}
+578
View File
@@ -0,0 +1,578 @@
// KST: Know Sure Thing Oscillator
// Weighted sum of 4 smoothed Rate-of-Change values + signal line (SMA of KST).
// Formula: KST = 1*SMA(ROC(r1),s1) + 2*SMA(ROC(r2),s2) + 3*SMA(ROC(r3),s3) + 4*SMA(ROC(r4),s4)
// Signal = SMA(KST, sigPeriod)
// Source: Martin Pring, "The KST System", Technical Analysis of Stocks & Commodities (1992)
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// KST: Know Sure Thing Oscillator
/// </summary>
/// <remarks>
/// KST combines four Rate-of-Change values at progressively longer lookback periods,
/// smooths each with an independent SMA, then combines with linear weights (1,2,3,4).
/// A signal line (SMA of KST) provides crossover triggers.
///
/// Calculation:
/// 1. ROC_k = (price / price[r_k] - 1) * 100 for k in {1,2,3,4}
/// 2. SM_k = SMA(ROC_k, s_k)
/// 3. KST = 1*SM1 + 2*SM2 + 3*SM3 + 4*SM4
/// 4. Signal = SMA(KST, sigPeriod)
///
/// Default parameters: r=(10,15,20,30), s=(10,10,10,15), sigPeriod=9
///
/// Sources:
/// - Pring, M.J. (1992). "The KST System." Technical Analysis of Stocks &amp; Commodities
/// - Pring, M.J. (2002). Technical Analysis Explained, 4th ed. McGraw-Hill
/// </remarks>
[SkipLocalsInit]
public sealed class Kst : ITValuePublisher
{
private const int DefaultR1 = 10;
private const int DefaultR2 = 15;
private const int DefaultR3 = 20;
private const int DefaultR4 = 30;
private const int DefaultS1 = 10;
private const int DefaultS2 = 10;
private const int DefaultS3 = 10;
private const int DefaultS4 = 15;
private const int DefaultSigPeriod = 9;
private readonly int _r1, _r2, _r3, _r4;
private readonly int _s1, _s2, _s3, _s4;
private readonly int _sigPeriod;
// ROC lookback circular buffers — ring size = rN+1 (slot 0 is overwritten when full)
private readonly double[] _p1, _p2, _p3, _p4;
// SMA running-sum circular buffers for each ROC channel
private readonly double[] _sma1, _sma2, _sma3, _sma4;
// SMA buffer for signal line
private readonly double[] _sigBuf;
// All scalar state in one record struct — enables _ps = _s snapshot for bar-correction.
// PrevXxx fields capture the ring-buffer slot value BEFORE each isNew=true write,
// so isNew=false can restore those slots to their pre-write state.
[StructLayout(LayoutKind.Auto)]
private record struct State(
int P1Head, int P2Head, int P3Head, int P4Head,
double PrevP1, double PrevP2, double PrevP3, double PrevP4,
double Sum1, int SmaHead1, int SmaCount1, double PrevSma1,
double Sum2, int SmaHead2, int SmaCount2, double PrevSma2,
double Sum3, int SmaHead3, int SmaCount3, double PrevSma3,
double Sum4, int SmaHead4, int SmaCount4, double PrevSma4,
double SigSum, int SigHead, int SigCount, double PrevSig,
int Count, double LastValidPrice);
private State _s;
private State _ps;
public string Name { get; }
public int WarmupPeriod { get; }
public TValue Last { get; private set; }
/// <summary>Primary KST line value.</summary>
public TValue KstValue { get; private set; }
/// <summary>Signal line value (SMA of KST).</summary>
public TValue Signal { get; private set; }
/// <summary>True when enough bars have been processed for valid output.</summary>
public bool IsHot => _s.Count >= WarmupPeriod;
public event TValuePublishedHandler? Pub;
public Kst(
int r1 = DefaultR1, int r2 = DefaultR2, int r3 = DefaultR3, int r4 = DefaultR4,
int s1 = DefaultS1, int s2 = DefaultS2, int s3 = DefaultS3, int s4 = DefaultS4,
int sigPeriod = DefaultSigPeriod)
{
if (r1 <= 0)
{
throw new ArgumentException("ROC period 1 must be greater than 0", nameof(r1));
}
if (r2 <= 0)
{
throw new ArgumentException("ROC period 2 must be greater than 0", nameof(r2));
}
if (r3 <= 0)
{
throw new ArgumentException("ROC period 3 must be greater than 0", nameof(r3));
}
if (r4 <= 0)
{
throw new ArgumentException("ROC period 4 must be greater than 0", nameof(r4));
}
if (s1 <= 0)
{
throw new ArgumentException("SMA period 1 must be greater than 0", nameof(s1));
}
if (s2 <= 0)
{
throw new ArgumentException("SMA period 2 must be greater than 0", nameof(s2));
}
if (s3 <= 0)
{
throw new ArgumentException("SMA period 3 must be greater than 0", nameof(s3));
}
if (s4 <= 0)
{
throw new ArgumentException("SMA period 4 must be greater than 0", nameof(s4));
}
if (sigPeriod <= 0)
{
throw new ArgumentException("Signal period must be greater than 0", nameof(sigPeriod));
}
_r1 = r1; _r2 = r2; _r3 = r3; _r4 = r4;
_s1 = s1; _s2 = s2; _s3 = s3; _s4 = s4;
_sigPeriod = sigPeriod;
_p1 = new double[r1 + 1];
_p2 = new double[r2 + 1];
_p3 = new double[r3 + 1];
_p4 = new double[r4 + 1];
_sma1 = new double[s1];
_sma2 = new double[s2];
_sma3 = new double[s3];
_sma4 = new double[s4];
_sigBuf = new double[sigPeriod];
// Warmup: need max_roc bars until ROC valid + max_sma for SMA warmup + sig for signal warmup
WarmupPeriod = Math.Max(Math.Max(r1, r2), Math.Max(r3, r4))
+ Math.Max(Math.Max(s1, s2), Math.Max(s3, s4))
+ sigPeriod - 2;
_s = default;
_ps = _s;
Name = $"Kst({r1},{r2},{r3},{r4},{s1},{s2},{s3},{s4},{sigPeriod})";
}
public Kst(ITValuePublisher source,
int r1 = DefaultR1, int r2 = DefaultR2, int r3 = DefaultR3, int r4 = DefaultR4,
int s1 = DefaultS1, int s2 = DefaultS2, int s3 = DefaultS3, int s4 = DefaultS4,
int sigPeriod = DefaultSigPeriod)
: this(r1, r2, r3, r4, s1, s2, s3, s4, sigPeriod)
{
source.Pub += Handle;
}
private void Handle(object? sender, in TValueEventArgs 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(TValue input, bool isNew = true)
{
if (isNew)
{
_ps = _s;
}
else
{
// Restore ring-buffer slots that were overwritten by the most recent isNew=true call.
// _ps.XxxHead = slot index written during bar N (head BEFORE the advance).
// _s.PrevXxx = the value that was at that slot BEFORE bar N wrote it.
// Using _s (not _ps) for values because _s captured them during bar N processing.
_p1[_ps.P1Head] = _s.PrevP1;
_p2[_ps.P2Head] = _s.PrevP2;
_p3[_ps.P3Head] = _s.PrevP3;
_p4[_ps.P4Head] = _s.PrevP4;
_sma1[_ps.SmaHead1] = _s.PrevSma1;
_sma2[_ps.SmaHead2] = _s.PrevSma2;
_sma3[_ps.SmaHead3] = _s.PrevSma3;
_sma4[_ps.SmaHead4] = _s.PrevSma4;
_sigBuf[_ps.SigHead] = _s.PrevSig;
_s = _ps;
}
// Local copy for JIT register promotion
int p1H = _s.P1Head, p2H = _s.P2Head, p3H = _s.P3Head, p4H = _s.P4Head;
double sum1 = _s.Sum1; int sh1 = _s.SmaHead1; int sc1 = _s.SmaCount1;
double sum2 = _s.Sum2; int sh2 = _s.SmaHead2; int sc2 = _s.SmaCount2;
double sum3 = _s.Sum3; int sh3 = _s.SmaHead3; int sc3 = _s.SmaCount3;
double sum4 = _s.Sum4; int sh4 = _s.SmaHead4; int sc4 = _s.SmaCount4;
double sigSum = _s.SigSum; int sigH = _s.SigHead; int sigC = _s.SigCount;
int count = _s.Count;
double lastValid = _s.LastValidPrice;
double price = input.Value;
if (!double.IsFinite(price))
{
price = double.IsFinite(lastValid) ? lastValid : 0.0;
}
else
{
lastValid = price;
}
if (isNew)
{
count++;
}
// ── ROC lookback ring buffers ─────────────────────────────────────────
// Capture the slot value BEFORE writing (needed to restore on next isNew=false call)
double prevP1 = _p1[p1H];
double prevP2 = _p2[p2H];
double prevP3 = _p3[p3H];
double prevP4 = _p4[p4H];
double prev1 = prevP1;
double prev2 = prevP2;
double prev3 = prevP3;
double prev4 = prevP4;
_p1[p1H] = price;
_p2[p2H] = price;
_p3[p3H] = price;
_p4[p4H] = price;
if (isNew)
{
p1H = (p1H + 1) % (_r1 + 1);
p2H = (p2H + 1) % (_r2 + 1);
p3H = (p3H + 1) % (_r3 + 1);
p4H = (p4H + 1) % (_r4 + 1);
}
// ── ROC values ────────────────────────────────────────────────────────
double roc1 = prev1 != 0.0 ? 100.0 * (price - prev1) / prev1 : 0.0;
double roc2 = prev2 != 0.0 ? 100.0 * (price - prev2) / prev2 : 0.0;
double roc3 = prev3 != 0.0 ? 100.0 * (price - prev3) / prev3 : 0.0;
double roc4 = prev4 != 0.0 ? 100.0 * (price - prev4) / prev4 : 0.0;
// ── SMA of each ROC via running-sum ring buffer ───────────────────────
double sm1 = StepSma(_sma1, ref sum1, ref sh1, ref sc1, roc1, _s1, isNew, out double prevSma1);
double sm2 = StepSma(_sma2, ref sum2, ref sh2, ref sc2, roc2, _s2, isNew, out double prevSma2);
double sm3 = StepSma(_sma3, ref sum3, ref sh3, ref sc3, roc3, _s3, isNew, out double prevSma3);
double sm4 = StepSma(_sma4, ref sum4, ref sh4, ref sc4, roc4, _s4, isNew, out double prevSma4);
// ── KST composite (weighted sum, FMA for w1..w3) ─────────────────────
double kstVal = Math.FusedMultiplyAdd(3.0, sm3, Math.FusedMultiplyAdd(2.0, sm2, sm1))
+ 4.0 * sm4;
// ── Signal line (SMA of KST) ──────────────────────────────────────────
double sigVal = StepSma(_sigBuf, ref sigSum, ref sigH, ref sigC, kstVal, _sigPeriod, isNew, out double prevSig);
// ── Write back local state (including pre-write slot snapshots) ───────
_s = new State(
p1H, p2H, p3H, p4H,
prevP1, prevP2, prevP3, prevP4,
sum1, sh1, sc1, prevSma1,
sum2, sh2, sc2, prevSma2,
sum3, sh3, sc3, prevSma3,
sum4, sh4, sc4, prevSma4,
sigSum, sigH, sigC, prevSig,
count, lastValid);
KstValue = new TValue(input.Time, kstVal);
Signal = new TValue(input.Time, sigVal);
Last = KstValue;
PubEvent(Last, isNew);
return Last;
}
/// <summary>Updates streaming state from a <see cref="TSeries"/> and returns dual output series.</summary>
public (TSeries Kst, TSeries Signal) Update(TSeries source)
{
int len = source.Count;
if (len == 0)
{
return (new TSeries([], []), new TSeries([], []));
}
var tK = new List<long>(len);
var vK = new List<double>(len);
var tS = new List<long>(len);
var vS = new List<double>(len);
CollectionsMarshal.SetCount(tK, len);
CollectionsMarshal.SetCount(vK, len);
CollectionsMarshal.SetCount(tS, len);
CollectionsMarshal.SetCount(vS, len);
Batch(source.Values,
CollectionsMarshal.AsSpan(vK),
CollectionsMarshal.AsSpan(vS),
_r1, _r2, _r3, _r4, _s1, _s2, _s3, _s4, _sigPeriod);
var tSpan = CollectionsMarshal.AsSpan(tK);
source.Times.CopyTo(tSpan);
tSpan.CopyTo(CollectionsMarshal.AsSpan(tS));
// Prime streaming state for continued updates
Reset();
for (int i = 0; i < len; i++)
{
Update(new TValue(source.Times[i], source.Values[i]), isNew: true);
}
return (new TSeries(tK, vK), new TSeries(tS, vS));
}
/// <summary>Resets all internal state.</summary>
public void Reset()
{
Array.Clear(_p1);
Array.Clear(_p2);
Array.Clear(_p3);
Array.Clear(_p4);
Array.Clear(_sma1);
Array.Clear(_sma2);
Array.Clear(_sma3);
Array.Clear(_sma4);
Array.Clear(_sigBuf);
_s = default;
_ps = _s;
Last = default;
KstValue = default;
Signal = default;
}
// ── Static Span Batch ────────────────────────────────────────────────────
/// <summary>
/// Calculates KST and Signal for the full source span. Uses ArrayPool for all intermediate buffers.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(
ReadOnlySpan<double> source,
Span<double> kstOut,
Span<double> sigOut,
int r1 = DefaultR1, int r2 = DefaultR2, int r3 = DefaultR3, int r4 = DefaultR4,
int s1 = DefaultS1, int s2 = DefaultS2, int s3 = DefaultS3, int s4 = DefaultS4,
int sigPeriod = DefaultSigPeriod)
{
if (source.Length != kstOut.Length)
{
throw new ArgumentException("Source and kstOut must have the same length", nameof(kstOut));
}
if (source.Length != sigOut.Length)
{
throw new ArgumentException("Source and sigOut must have the same length", nameof(sigOut));
}
if (r1 <= 0)
{
throw new ArgumentException("ROC period 1 must be greater than 0", nameof(r1));
}
if (r2 <= 0)
{
throw new ArgumentException("ROC period 2 must be greater than 0", nameof(r2));
}
if (r3 <= 0)
{
throw new ArgumentException("ROC period 3 must be greater than 0", nameof(r3));
}
if (r4 <= 0)
{
throw new ArgumentException("ROC period 4 must be greater than 0", nameof(r4));
}
if (s1 <= 0)
{
throw new ArgumentException("SMA period 1 must be greater than 0", nameof(s1));
}
if (s2 <= 0)
{
throw new ArgumentException("SMA period 2 must be greater than 0", nameof(s2));
}
if (s3 <= 0)
{
throw new ArgumentException("SMA period 3 must be greater than 0", nameof(s3));
}
if (s4 <= 0)
{
throw new ArgumentException("SMA period 4 must be greater than 0", nameof(s4));
}
if (sigPeriod <= 0)
{
throw new ArgumentException("Signal period must be greater than 0", nameof(sigPeriod));
}
int len = source.Length;
if (len == 0)
{
return;
}
int rBuf1 = r1 + 1, rBuf2 = r2 + 1, rBuf3 = r3 + 1, rBuf4 = r4 + 1;
double[] p1 = ArrayPool<double>.Shared.Rent(rBuf1);
double[] p2 = ArrayPool<double>.Shared.Rent(rBuf2);
double[] p3 = ArrayPool<double>.Shared.Rent(rBuf3);
double[] p4 = ArrayPool<double>.Shared.Rent(rBuf4);
double[] sm1b = ArrayPool<double>.Shared.Rent(s1);
double[] sm2b = ArrayPool<double>.Shared.Rent(s2);
double[] sm3b = ArrayPool<double>.Shared.Rent(s3);
double[] sm4b = ArrayPool<double>.Shared.Rent(s4);
double[] sigb = ArrayPool<double>.Shared.Rent(sigPeriod);
p1.AsSpan(0, rBuf1).Clear();
p2.AsSpan(0, rBuf2).Clear();
p3.AsSpan(0, rBuf3).Clear();
p4.AsSpan(0, rBuf4).Clear();
sm1b.AsSpan(0, s1).Clear();
sm2b.AsSpan(0, s2).Clear();
sm3b.AsSpan(0, s3).Clear();
sm4b.AsSpan(0, s4).Clear();
sigb.AsSpan(0, sigPeriod).Clear();
try
{
int ph1 = 0, ph2 = 0, ph3 = 0, ph4 = 0;
double sum1 = 0, sum2 = 0, sum3 = 0, sum4 = 0, sumSig = 0;
int sh1 = 0, sh2 = 0, sh3 = 0, sh4 = 0, shSig = 0;
int sc1 = 0, sc2 = 0, sc3 = 0, sc4 = 0, scSig = 0;
double lastValid = 0.0;
for (int i = 0; i < len; i++)
{
double price = source[i];
if (!double.IsFinite(price))
{
price = lastValid;
}
else
{
lastValid = price;
}
double prev1 = p1[ph1]; p1[ph1] = price; ph1 = (ph1 + 1) % rBuf1;
double prev2 = p2[ph2]; p2[ph2] = price; ph2 = (ph2 + 1) % rBuf2;
double prev3 = p3[ph3]; p3[ph3] = price; ph3 = (ph3 + 1) % rBuf3;
double prev4 = p4[ph4]; p4[ph4] = price; ph4 = (ph4 + 1) % rBuf4;
double roc1 = prev1 != 0.0 ? 100.0 * (price - prev1) / prev1 : 0.0;
double roc2 = prev2 != 0.0 ? 100.0 * (price - prev2) / prev2 : 0.0;
double roc3 = prev3 != 0.0 ? 100.0 * (price - prev3) / prev3 : 0.0;
double roc4 = prev4 != 0.0 ? 100.0 * (price - prev4) / prev4 : 0.0;
double sm1 = BatchStepSma(sm1b, s1, ref sum1, ref sh1, ref sc1, roc1);
double sm2 = BatchStepSma(sm2b, s2, ref sum2, ref sh2, ref sc2, roc2);
double sm3 = BatchStepSma(sm3b, s3, ref sum3, ref sh3, ref sc3, roc3);
double sm4 = BatchStepSma(sm4b, s4, ref sum4, ref sh4, ref sc4, roc4);
double kstVal = Math.FusedMultiplyAdd(3.0, sm3, Math.FusedMultiplyAdd(2.0, sm2, sm1))
+ 4.0 * sm4;
sigOut[i] = BatchStepSma(sigb, sigPeriod, ref sumSig, ref shSig, ref scSig, kstVal);
kstOut[i] = kstVal;
}
}
finally
{
ArrayPool<double>.Shared.Return(p1);
ArrayPool<double>.Shared.Return(p2);
ArrayPool<double>.Shared.Return(p3);
ArrayPool<double>.Shared.Return(p4);
ArrayPool<double>.Shared.Return(sm1b);
ArrayPool<double>.Shared.Return(sm2b);
ArrayPool<double>.Shared.Return(sm3b);
ArrayPool<double>.Shared.Return(sm4b);
ArrayPool<double>.Shared.Return(sigb);
}
}
/// <summary>Calculates KST for an entire <see cref="TSeries"/>.</summary>
public static (TSeries Kst, TSeries Signal) Batch(
TSeries source,
int r1 = DefaultR1, int r2 = DefaultR2, int r3 = DefaultR3, int r4 = DefaultR4,
int s1 = DefaultS1, int s2 = DefaultS2, int s3 = DefaultS3, int s4 = DefaultS4,
int sigPeriod = DefaultSigPeriod)
{
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 tS = new List<long>(len);
var vS = new List<double>(len);
CollectionsMarshal.SetCount(tK, len);
CollectionsMarshal.SetCount(vK, len);
CollectionsMarshal.SetCount(tS, len);
CollectionsMarshal.SetCount(vS, len);
Batch(source.Values, CollectionsMarshal.AsSpan(vK), CollectionsMarshal.AsSpan(vS),
r1, r2, r3, r4, s1, s2, s3, s4, sigPeriod);
var tSpan = CollectionsMarshal.AsSpan(tK);
source.Times.CopyTo(tSpan);
tSpan.CopyTo(CollectionsMarshal.AsSpan(tS));
return (new TSeries(tK, vK), new TSeries(tS, vS));
}
/// <summary>Creates a KST indicator and calculates results for the source series.</summary>
public static ((TSeries Kst, TSeries Signal) Results, Kst Indicator) Calculate(
TSeries source,
int r1 = DefaultR1, int r2 = DefaultR2, int r3 = DefaultR3, int r4 = DefaultR4,
int s1 = DefaultS1, int s2 = DefaultS2, int s3 = DefaultS3, int s4 = DefaultS4,
int sigPeriod = DefaultSigPeriod)
{
var indicator = new Kst(r1, r2, r3, r4, s1, s2, s3, s4, sigPeriod);
var results = indicator.Update(source);
return (results, indicator);
}
// ── Private helpers ──────────────────────────────────────────────────────
/// <summary>
/// O(1) circular-buffer SMA with running sum.
/// Returns the previous slot value (for bar-correction state capture) via <paramref name="prevSlot"/>.
/// When isNew=false the head is not advanced (same slot overwritten for bar correction).
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double StepSma(
double[] buf, ref double sum, ref int head, ref int count,
double value, int period, bool isNew, out double prevSlot)
{
int h = head;
double oldest = buf[h];
prevSlot = oldest; // capture the value being overwritten
if (count < period)
{
sum += value - oldest;
count++;
}
else
{
sum = sum - oldest + value;
}
buf[h] = value;
if (isNew)
{
head = (h + 1) % period;
}
return sum / Math.Max(1, count);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static double BatchStepSma(
double[] buf, int period,
ref double sum, ref int head, ref int count,
double value)
{
int h = head;
double oldest = buf[h];
if (count < period)
{
sum += value - oldest;
count++;
}
else
{
sum = sum - oldest + value;
}
buf[h] = value;
head = (h + 1) % period;
return sum / Math.Max(1, count);
}
}