mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-21 20:18: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,111 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using QuanTAlib;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class SmiIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void SmiIndicator_Constructor_SetsDefaults()
|
||||
{
|
||||
var indicator = new SmiIndicator();
|
||||
|
||||
Assert.Equal(10, indicator.KPeriod);
|
||||
Assert.Equal(3, indicator.KSmooth);
|
||||
Assert.Equal(3, indicator.DSmooth);
|
||||
Assert.True(indicator.Blau);
|
||||
Assert.True(indicator.ShowColdValues);
|
||||
Assert.Equal("SMI", indicator.Name);
|
||||
Assert.True(indicator.SeparateWindow);
|
||||
Assert.True(indicator.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmiIndicator_MinHistoryDepths_EqualsZero()
|
||||
{
|
||||
var indicator = new SmiIndicator { KPeriod = 14, KSmooth = 5, DSmooth = 5 };
|
||||
|
||||
Assert.Equal(0, SmiIndicator.MinHistoryDepths);
|
||||
IWatchlistIndicator watchlistIndicator = indicator;
|
||||
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmiIndicator_ShortName_IncludesParameters()
|
||||
{
|
||||
var indicator = new SmiIndicator { KPeriod = 14, KSmooth = 5, DSmooth = 5 };
|
||||
indicator.Initialize();
|
||||
|
||||
Assert.Contains("SMI", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("14", indicator.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("5", indicator.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmiIndicator_SourceCodeLink_IsValid()
|
||||
{
|
||||
var indicator = new SmiIndicator();
|
||||
|
||||
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
Assert.Contains("Smi.Quantower.cs", indicator.SourceCodeLink, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmiIndicator_Initialize_CreatesInternalSmi()
|
||||
{
|
||||
var indicator = new SmiIndicator { KPeriod = 10, KSmooth = 3, DSmooth = 3 };
|
||||
|
||||
indicator.Initialize();
|
||||
|
||||
// After init, line series should exist (K, D)
|
||||
Assert.Equal(2, indicator.LinesSeries.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmiIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
|
||||
{
|
||||
var indicator = new SmiIndicator { KPeriod = 5, KSmooth = 3, DSmooth = 3 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
|
||||
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
double k = indicator.LinesSeries[0].GetValue(0);
|
||||
double d = indicator.LinesSeries[1].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(k));
|
||||
Assert.True(double.IsFinite(d));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SmiIndicator_ProcessUpdate_NewBar_ComputesValue()
|
||||
{
|
||||
var indicator = new SmiIndicator { KPeriod = 5, KSmooth = 3, DSmooth = 3 };
|
||||
indicator.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
|
||||
var args = new UpdateArgs(UpdateReason.HistoricalBar);
|
||||
indicator.ProcessUpdate(args);
|
||||
}
|
||||
|
||||
// Simulate a new bar
|
||||
indicator.HistoricalData.AddBar(now.AddMinutes(10), 110, 120, 100, 115);
|
||||
var newArgs = new UpdateArgs(UpdateReason.NewBar);
|
||||
indicator.ProcessUpdate(newArgs);
|
||||
|
||||
double k = indicator.LinesSeries[0].GetValue(0);
|
||||
double d = indicator.LinesSeries[1].GetValue(0);
|
||||
|
||||
Assert.True(double.IsFinite(k));
|
||||
Assert.True(double.IsFinite(d));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Drawing;
|
||||
using System.Runtime.CompilerServices;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
[SkipLocalsInit]
|
||||
public sealed class SmiIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("K Period", sortIndex: 1, 1, 500, 1, 0)]
|
||||
public int KPeriod { get; set; } = 10;
|
||||
|
||||
[InputParameter("K Smooth", sortIndex: 2, 1, 100, 1, 0)]
|
||||
public int KSmooth { get; set; } = 3;
|
||||
|
||||
[InputParameter("D Smooth", sortIndex: 3, 1, 100, 1, 0)]
|
||||
public int DSmooth { get; set; } = 3;
|
||||
|
||||
[InputParameter("Use Blau method", sortIndex: 4)]
|
||||
public bool Blau { get; set; } = true;
|
||||
|
||||
[InputParameter("Show cold values", sortIndex: 21)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Smi _smi = null!;
|
||||
private readonly LineSeries _kSeries;
|
||||
private readonly LineSeries _dSeries;
|
||||
|
||||
public static int MinHistoryDepths => 0;
|
||||
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
|
||||
|
||||
public override string ShortName => $"SMI {KPeriod},{KSmooth},{DSmooth}";
|
||||
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/oscillators/smi/Smi.Quantower.cs";
|
||||
|
||||
public SmiIndicator()
|
||||
{
|
||||
OnBackGround = true;
|
||||
SeparateWindow = true;
|
||||
Name = "SMI";
|
||||
Description = "Stochastic Momentum Index with K and D 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);
|
||||
|
||||
AddLineSeries(_kSeries);
|
||||
AddLineSeries(_dSeries);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnInit()
|
||||
{
|
||||
_smi = new Smi(KPeriod, KSmooth, DSmooth, Blau);
|
||||
base.OnInit();
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
_smi.Update(this.GetInputBar(args), args.IsNewBar());
|
||||
|
||||
_kSeries.SetValue(_smi.K.Value, _smi.IsHot, ShowColdValues);
|
||||
_dSeries.SetValue(_smi.D.Value, _smi.IsHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class SmiTests
|
||||
{
|
||||
private const double Tolerance = 1e-10;
|
||||
|
||||
// --- A) Constructor validation ---
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ZeroKPeriod_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Smi(kPeriod: 0));
|
||||
Assert.Equal("kPeriod", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ZeroKSmooth_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Smi(kSmooth: 0));
|
||||
Assert.Equal("kSmooth", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ZeroDSmooth_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Smi(dSmooth: 0));
|
||||
Assert.Equal("dSmooth", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NegativeKPeriod_Throws()
|
||||
{
|
||||
var ex = Assert.Throws<ArgumentException>(() => new Smi(kPeriod: -1));
|
||||
Assert.Equal("kPeriod", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_Valid_SetsNameAndWarmup()
|
||||
{
|
||||
var smi = new Smi(10, 3, 3);
|
||||
Assert.Equal("Smi(10,3,3)", smi.Name);
|
||||
Assert.Equal(10 + 3 + 3, smi.WarmupPeriod);
|
||||
Assert.False(smi.IsHot);
|
||||
}
|
||||
|
||||
// --- B) Basic calculation ---
|
||||
|
||||
[Fact]
|
||||
public void Update_ConstantBars_KIsZero()
|
||||
{
|
||||
var smi = new Smi(5, 3, 3);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
long t = DateTime.UtcNow.Ticks + i;
|
||||
smi.Update(new TBar(t, 100, 100, 100, 100, 1000));
|
||||
}
|
||||
|
||||
Assert.Equal(0.0, smi.K.Value, 1e-6);
|
||||
Assert.Equal(0.0, smi.D.Value, 1e-6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_RisingClose_PositiveK()
|
||||
{
|
||||
var smi = new Smi(5, 3, 3);
|
||||
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
long t = DateTime.UtcNow.Ticks + i;
|
||||
double c = 100.0 + i;
|
||||
smi.Update(new TBar(t, c, c + 5, c - 5, c, 1000));
|
||||
}
|
||||
|
||||
Assert.True(smi.K.Value > 0.0, "Rising close should produce positive K");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_FallingClose_NegativeK()
|
||||
{
|
||||
var smi = new Smi(5, 3, 3);
|
||||
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
long t = DateTime.UtcNow.Ticks + i;
|
||||
double c = 200.0 - i;
|
||||
smi.Update(new TBar(t, c, c + 5, c - 5, c, 1000));
|
||||
}
|
||||
|
||||
Assert.True(smi.K.Value < 0.0, "Falling close should produce negative K");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Last_IsAccessible()
|
||||
{
|
||||
var smi = new Smi(5, 3, 3);
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
long t = DateTime.UtcNow.Ticks + i;
|
||||
smi.Update(new TBar(t, 100 + i, 110 + i, 90 + i, 105 + i, 1000));
|
||||
}
|
||||
|
||||
Assert.True(double.IsFinite(smi.Last.Value));
|
||||
Assert.True(double.IsFinite(smi.K.Value));
|
||||
Assert.True(double.IsFinite(smi.D.Value));
|
||||
Assert.True(smi.IsHot);
|
||||
}
|
||||
|
||||
// --- C) State + bar correction ---
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewTrue_AdvancesState()
|
||||
{
|
||||
var smi = new Smi(5, 3, 3);
|
||||
long t = DateTime.UtcNow.Ticks;
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
smi.Update(new TBar(t + i, 100 + i, 110 + i, 90 + i, 105 + i, 1000), isNew: true);
|
||||
}
|
||||
|
||||
double k1 = smi.K.Value;
|
||||
smi.Update(new TBar(t + 10, 120, 130, 110, 125, 1000), isNew: true);
|
||||
Assert.NotEqual(k1, smi.K.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IsNewFalse_Rollback()
|
||||
{
|
||||
var smi = new Smi(5, 3, 3);
|
||||
long t = DateTime.UtcNow.Ticks;
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
smi.Update(new TBar(t + i, 100 + i, 110 + i, 90 + i, 105 + i, 1000), isNew: true);
|
||||
}
|
||||
|
||||
double k1 = smi.K.Value;
|
||||
smi.Update(new TBar(t + 9, 200, 210, 190, 205, 1000), isNew: false);
|
||||
smi.Update(new TBar(t + 9, 100 + 9, 110 + 9, 90 + 9, 105 + 9, 1000), isNew: false);
|
||||
Assert.Equal(k1, smi.K.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_IterativeCorrection_Restores()
|
||||
{
|
||||
var smi = new Smi(5, 3, 3);
|
||||
long t = DateTime.UtcNow.Ticks;
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
smi.Update(new TBar(t + i, 100 + i, 110 + i, 90 + i, 105 + i, 1000), isNew: true);
|
||||
}
|
||||
|
||||
double k1 = smi.K.Value;
|
||||
|
||||
// Multiple corrections
|
||||
for (int c = 0; c < 5; c++)
|
||||
{
|
||||
smi.Update(new TBar(t + 9, 150 + c, 160 + c, 140 + c, 155 + c, 1000), isNew: false);
|
||||
}
|
||||
|
||||
// Restore original
|
||||
smi.Update(new TBar(t + 9, 109, 119, 99, 114, 1000), isNew: false);
|
||||
Assert.Equal(k1, smi.K.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsState()
|
||||
{
|
||||
var smi = new Smi(5, 3, 3);
|
||||
long t = DateTime.UtcNow.Ticks;
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
smi.Update(new TBar(t + i, 100 + i, 110 + i, 90 + i, 105 + i, 1000));
|
||||
}
|
||||
|
||||
Assert.True(smi.IsHot);
|
||||
|
||||
smi.Reset();
|
||||
|
||||
Assert.False(smi.IsHot);
|
||||
Assert.Equal(default, smi.Last);
|
||||
Assert.Equal(default, smi.K);
|
||||
Assert.Equal(default, smi.D);
|
||||
}
|
||||
|
||||
// --- D) Warmup/convergence ---
|
||||
|
||||
[Fact]
|
||||
public void IsHot_FlipsAtKPeriod()
|
||||
{
|
||||
var smi = new Smi(5, 3, 3);
|
||||
long t = DateTime.UtcNow.Ticks;
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
smi.Update(new TBar(t + i, 100, 110, 90, 100, 1000));
|
||||
Assert.False(smi.IsHot);
|
||||
}
|
||||
|
||||
smi.Update(new TBar(t + 4, 100, 110, 90, 100, 1000));
|
||||
Assert.True(smi.IsHot);
|
||||
}
|
||||
|
||||
// --- E) Robustness ---
|
||||
|
||||
[Fact]
|
||||
public void Update_NaN_UsesLastValid()
|
||||
{
|
||||
var smi = new Smi(5, 3, 3);
|
||||
long t = DateTime.UtcNow.Ticks;
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
smi.Update(new TBar(t + i, 100 + i, 110 + i, 90 + i, 105 + i, 1000));
|
||||
}
|
||||
|
||||
_ = smi.K.Value;
|
||||
|
||||
smi.Update(new TBar(t + 10, double.NaN, double.NaN, double.NaN, double.NaN, 1000));
|
||||
Assert.True(double.IsFinite(smi.K.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_Infinity_UsesLastValid()
|
||||
{
|
||||
var smi = new Smi(5, 3, 3);
|
||||
long t = DateTime.UtcNow.Ticks;
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
smi.Update(new TBar(t + i, 100 + i, 110 + i, 90 + i, 105 + i, 1000));
|
||||
}
|
||||
|
||||
smi.Update(new TBar(t + 10, double.PositiveInfinity, double.PositiveInfinity, double.NegativeInfinity, double.PositiveInfinity, 1000));
|
||||
Assert.True(double.IsFinite(smi.K.Value));
|
||||
}
|
||||
|
||||
// --- F) Consistency ---
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceConsistentResults_Blau()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
const int kPeriod = 10;
|
||||
const int kSmooth = 3;
|
||||
const int dSmooth = 3;
|
||||
const bool blau = true;
|
||||
|
||||
// Streaming
|
||||
var smiStream = new Smi(kPeriod, kSmooth, dSmooth, blau);
|
||||
var streamK = new double[bars.Count];
|
||||
var streamD = new double[bars.Count];
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
smiStream.Update(bars[i]);
|
||||
streamK[i] = smiStream.K.Value;
|
||||
streamD[i] = smiStream.D.Value;
|
||||
}
|
||||
|
||||
// Batch (TBarSeries)
|
||||
var (batchK, batchD) = Smi.Batch(bars, kPeriod, kSmooth, dSmooth, blau);
|
||||
|
||||
// Span
|
||||
var spanK = new double[bars.Count];
|
||||
var spanD = new double[bars.Count];
|
||||
Smi.Batch(bars.High.Values, bars.Low.Values, bars.Close.Values,
|
||||
spanK, spanD, kPeriod, kSmooth, dSmooth, blau);
|
||||
|
||||
// Event
|
||||
var smiEvent = new Smi(kPeriod, kSmooth, dSmooth, blau);
|
||||
var eventK = new double[bars.Count];
|
||||
var eventD = new double[bars.Count];
|
||||
int idx = 0;
|
||||
smiEvent.Pub += (_, in e) =>
|
||||
{
|
||||
if (idx < bars.Count)
|
||||
{
|
||||
eventK[idx] = smiEvent.K.Value;
|
||||
eventD[idx] = smiEvent.D.Value;
|
||||
idx++;
|
||||
}
|
||||
};
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
smiEvent.Update(bars[i]);
|
||||
}
|
||||
|
||||
// Compare last 50 values (after warmup stabilizes)
|
||||
for (int i = 150; i < bars.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamK[i], batchK[i].Value, 1e-6);
|
||||
Assert.Equal(streamD[i], batchD[i].Value, 1e-6);
|
||||
Assert.Equal(streamK[i], spanK[i], 1e-6);
|
||||
Assert.Equal(streamD[i], spanD[i], 1e-6);
|
||||
Assert.Equal(streamK[i], eventK[i], Tolerance);
|
||||
Assert.Equal(streamD[i], eventD[i], Tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllModes_ProduceConsistentResults_ChandeKroll()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
|
||||
var bars = gbm.Fetch(200, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
const int kPeriod = 10;
|
||||
const int kSmooth = 3;
|
||||
const int dSmooth = 3;
|
||||
const bool blau = false;
|
||||
|
||||
var smiStream = new Smi(kPeriod, kSmooth, dSmooth, blau);
|
||||
var streamK = new double[bars.Count];
|
||||
var streamD = new double[bars.Count];
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
smiStream.Update(bars[i]);
|
||||
streamK[i] = smiStream.K.Value;
|
||||
streamD[i] = smiStream.D.Value;
|
||||
}
|
||||
|
||||
var spanK = new double[bars.Count];
|
||||
var spanD = new double[bars.Count];
|
||||
Smi.Batch(bars.High.Values, bars.Low.Values, bars.Close.Values,
|
||||
spanK, spanD, kPeriod, kSmooth, dSmooth, blau);
|
||||
|
||||
for (int i = 150; i < bars.Count; i++)
|
||||
{
|
||||
Assert.Equal(streamK[i], spanK[i], 1e-6);
|
||||
Assert.Equal(streamD[i], spanD[i], 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
// --- G) Span API tests ---
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_MismatchedInputLength_Throws()
|
||||
{
|
||||
var high = new double[10];
|
||||
var low = new double[5];
|
||||
var close = new double[10];
|
||||
var kOut = new double[10];
|
||||
var dOut = new double[10];
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
Smi.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), kOut.AsSpan(), dOut.AsSpan()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_OutputTooSmall_Throws()
|
||||
{
|
||||
var high = new double[10];
|
||||
var low = new double[10];
|
||||
var close = new double[10];
|
||||
var kOut = new double[5]; // too small
|
||||
var dOut = new double[10];
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
Smi.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), kOut.AsSpan(), dOut.AsSpan()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_DOutputTooSmall_Throws()
|
||||
{
|
||||
var high = new double[10];
|
||||
var low = new double[10];
|
||||
var close = new double[10];
|
||||
var kOut = new double[10];
|
||||
var dOut = new double[5]; // too small
|
||||
|
||||
Assert.Throws<ArgumentException>(() =>
|
||||
Smi.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), kOut.AsSpan(), dOut.AsSpan()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_Empty_NoException()
|
||||
{
|
||||
var empty = Array.Empty<double>();
|
||||
Smi.Batch(empty, empty, empty, empty, empty);
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_InvalidKPeriod_Throws()
|
||||
{
|
||||
var h = new double[10];
|
||||
var l = new double[10];
|
||||
var c = new double[10];
|
||||
var k = new double[10];
|
||||
var d = new double[10];
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() =>
|
||||
Smi.Batch(h, l, c, k, d, kPeriod: 0));
|
||||
Assert.Equal("kPeriod", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_LargeData_NoStackOverflow()
|
||||
{
|
||||
int size = 1000;
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 99);
|
||||
var bars = gbm.Fetch(size, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var kOut = new double[size];
|
||||
var dOut = new double[size];
|
||||
|
||||
Smi.Batch(bars.High.Values, bars.Low.Values, bars.Close.Values, kOut, dOut);
|
||||
Assert.True(double.IsFinite(kOut[size - 1]));
|
||||
Assert.True(double.IsFinite(dOut[size - 1]));
|
||||
}
|
||||
|
||||
// --- H) Chainability ---
|
||||
|
||||
[Fact]
|
||||
public void PubEvent_Fires()
|
||||
{
|
||||
var smi = new Smi(5, 3, 3);
|
||||
int pubCount = 0;
|
||||
smi.Pub += (_, in _) => pubCount++;
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
long t = DateTime.UtcNow.Ticks + i;
|
||||
smi.Update(new TBar(t, 100 + i, 110 + i, 90 + i, 105 + i, 1000));
|
||||
}
|
||||
|
||||
Assert.Equal(10, pubCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventChaining_Works()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
var smi = new Smi(bars, 10, 3, 3);
|
||||
|
||||
Assert.True(smi.IsHot);
|
||||
Assert.True(double.IsFinite(smi.K.Value));
|
||||
Assert.True(double.IsFinite(smi.D.Value));
|
||||
}
|
||||
|
||||
// --- TValue overload ---
|
||||
|
||||
[Fact]
|
||||
public void Update_TValue_ReturnsFinite()
|
||||
{
|
||||
var smi = new Smi(5, 3, 3);
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
long t = DateTime.UtcNow.Ticks + i;
|
||||
var result = smi.Update(new TValue(t, 100.0 + i));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Blau vs Chande/Kroll produce different results ---
|
||||
|
||||
[Fact]
|
||||
public void BlauVsChandeKroll_ProduceDifferentResults()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.2, seed: 77);
|
||||
var bars = gbm.Fetch(100, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
var smiBlau = new Smi(10, 3, 3, blau: true);
|
||||
var smiCk = new Smi(10, 3, 3, blau: false);
|
||||
|
||||
for (int i = 0; i < bars.Count; i++)
|
||||
{
|
||||
smiBlau.Update(bars[i]);
|
||||
smiCk.Update(bars[i]);
|
||||
}
|
||||
|
||||
// They should produce different K values (different algorithms)
|
||||
Assert.NotEqual(smiBlau.K.Value, smiCk.K.Value, 1e-6);
|
||||
}
|
||||
|
||||
// --- Static batch ---
|
||||
|
||||
[Fact]
|
||||
public void StaticBatch_Works()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
var (k, d) = Smi.Batch(bars, 10, 3, 3);
|
||||
|
||||
Assert.Equal(50, k.Count);
|
||||
Assert.Equal(50, d.Count);
|
||||
Assert.True(double.IsFinite(k.Last.Value));
|
||||
Assert.True(double.IsFinite(d.Last.Value));
|
||||
}
|
||||
|
||||
// --- Calculate factory ---
|
||||
|
||||
[Fact]
|
||||
public void Calculate_ReturnsResultsAndIndicator()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
|
||||
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
|
||||
var (results, indicator) = Smi.Calculate(bars, 10, 3, 3);
|
||||
|
||||
Assert.Equal(50, results.K.Count);
|
||||
Assert.Equal(50, results.D.Count);
|
||||
Assert.True(indicator.IsHot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class SmiValidationTests
|
||||
{
|
||||
private static TBarSeries GenerateSeries(int count, int seed = 42)
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: seed);
|
||||
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
// --- A) Streaming vs Batch agreement ---
|
||||
|
||||
[Fact]
|
||||
public void Streaming_Matches_Batch_Blau()
|
||||
{
|
||||
var series = GenerateSeries(300);
|
||||
const int kPeriod = 10;
|
||||
const int kSmooth = 3;
|
||||
const int dSmooth = 3;
|
||||
|
||||
var smi = new Smi(kPeriod, kSmooth, dSmooth, blau: true);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
smi.Update(series[i]);
|
||||
}
|
||||
|
||||
var (batchK, batchD) = Smi.Batch(series, kPeriod, kSmooth, dSmooth, blau: true);
|
||||
|
||||
Assert.Equal(smi.K.Value, batchK[^1].Value, 1e-6);
|
||||
Assert.Equal(smi.D.Value, batchD[^1].Value, 1e-6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Streaming_Matches_Batch_ChandeKroll()
|
||||
{
|
||||
var series = GenerateSeries(300);
|
||||
const int kPeriod = 10;
|
||||
const int kSmooth = 3;
|
||||
const int dSmooth = 3;
|
||||
|
||||
var smi = new Smi(kPeriod, kSmooth, dSmooth, blau: false);
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
smi.Update(series[i]);
|
||||
}
|
||||
|
||||
var (batchK, batchD) = Smi.Batch(series, kPeriod, kSmooth, dSmooth, blau: false);
|
||||
|
||||
Assert.Equal(smi.K.Value, batchK[^1].Value, 1e-6);
|
||||
Assert.Equal(smi.D.Value, batchD[^1].Value, 1e-6);
|
||||
}
|
||||
|
||||
// --- B) SpanBatch vs TBarSeriesBatch ---
|
||||
|
||||
[Fact]
|
||||
public void SpanBatch_Matches_TBarSeriesBatch()
|
||||
{
|
||||
var series = GenerateSeries(200);
|
||||
const int kPeriod = 10;
|
||||
const int kSmooth = 3;
|
||||
const int dSmooth = 3;
|
||||
|
||||
var (batchK, batchD) = Smi.Batch(series, kPeriod, kSmooth, dSmooth);
|
||||
|
||||
var spanK = new double[series.Count];
|
||||
var spanD = new double[series.Count];
|
||||
Smi.Batch(series.High.Values, series.Low.Values, series.Close.Values,
|
||||
spanK, spanD, kPeriod, kSmooth, dSmooth);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
Assert.Equal(batchK[i].Value, spanK[i], 1e-10);
|
||||
Assert.Equal(batchD[i].Value, spanD[i], 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
// --- C) Directional correctness ---
|
||||
|
||||
[Fact]
|
||||
public void ConstantPrice_KIsZero()
|
||||
{
|
||||
var bars = new TBarSeries();
|
||||
long t = DateTime.UtcNow.Ticks;
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
bars.Add(new TBar(t + i, 50.0, 50.0, 50.0, 50.0, 1000));
|
||||
}
|
||||
|
||||
var (k, d) = Smi.Batch(bars, 10, 3, 3);
|
||||
Assert.Equal(0.0, k[^1].Value, 1e-6);
|
||||
Assert.Equal(0.0, d[^1].Value, 1e-6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PriceAboveMidpoint_PositiveK()
|
||||
{
|
||||
// Close consistently near high → positive SMI
|
||||
var bars = new TBarSeries();
|
||||
long t = DateTime.UtcNow.Ticks;
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
bars.Add(new TBar(t + i, 100, 110, 90, 109, 1000));
|
||||
}
|
||||
|
||||
var (k, _) = Smi.Batch(bars, 10, 3, 3);
|
||||
Assert.True(k[^1].Value > 0.0, "Close near high should produce positive K");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PriceBelowMidpoint_NegativeK()
|
||||
{
|
||||
// Close consistently near low → negative SMI
|
||||
var bars = new TBarSeries();
|
||||
long t = DateTime.UtcNow.Ticks;
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
bars.Add(new TBar(t + i, 100, 110, 90, 91, 1000));
|
||||
}
|
||||
|
||||
var (k, _) = Smi.Batch(bars, 10, 3, 3);
|
||||
Assert.True(k[^1].Value < 0.0, "Close near low should produce negative K");
|
||||
}
|
||||
|
||||
// --- D) Multi-period consistency ---
|
||||
|
||||
[Fact]
|
||||
public void DifferentPeriods_AllProduceFiniteResults()
|
||||
{
|
||||
var series = GenerateSeries(200);
|
||||
|
||||
int[] periods = [5, 10, 14, 20];
|
||||
foreach (int p in periods)
|
||||
{
|
||||
var (k, d) = Smi.Batch(series, kPeriod: p, kSmooth: 3, dSmooth: 3);
|
||||
Assert.Equal(200, k.Count);
|
||||
Assert.Equal(200, d.Count);
|
||||
Assert.True(double.IsFinite(k[^1].Value), $"K should be finite for kPeriod={p}");
|
||||
Assert.True(double.IsFinite(d[^1].Value), $"D should be finite for kPeriod={p}");
|
||||
}
|
||||
}
|
||||
|
||||
// --- E) Determinism ---
|
||||
|
||||
[Fact]
|
||||
public void MultipleRuns_ProduceIdenticalResults()
|
||||
{
|
||||
var series = GenerateSeries(100, seed: 55);
|
||||
|
||||
var (k1, d1) = Smi.Batch(series, 10, 3, 3);
|
||||
var (k2, d2) = Smi.Batch(series, 10, 3, 3);
|
||||
|
||||
for (int i = 0; i < series.Count; i++)
|
||||
{
|
||||
Assert.Equal(k1[i].Value, k2[i].Value, 1e-15);
|
||||
Assert.Equal(d1[i].Value, d2[i].Value, 1e-15);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// SMI: Stochastic Momentum Index
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Measures where the close sits relative to the midpoint of the recent
|
||||
/// high-low range, then double-smooths the result with cascaded EMAs.
|
||||
///
|
||||
/// Two methods are supported:
|
||||
/// <b>Blau</b> (default): compute ratio first, then smooth.
|
||||
/// raw = 100 × (close − midpoint) / rangeHalf
|
||||
/// K = EMA₂(EMA₁(raw, kSmooth), kSmooth)
|
||||
///
|
||||
/// <b>Chande/Kroll</b>: smooth numerator and denominator separately.
|
||||
/// K = 100 × EMA₂(EMA₁(close − midpoint)) / EMA₂(EMA₁(rangeHalf))
|
||||
///
|
||||
/// D (signal) = EMA(K, dSmooth) for both methods.
|
||||
/// Range: −100 to +100. Values beyond ±40 indicate extreme momentum.
|
||||
///
|
||||
/// References:
|
||||
/// William Blau, "Momentum, Direction, and Divergence" (1995)
|
||||
/// Tushar Chande & Stanley Kroll, "The New Technical Trader" (1994)
|
||||
/// PineScript reference: smi.pine
|
||||
/// </remarks>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Smi : ITValuePublisher
|
||||
{
|
||||
private readonly int _kPeriod;
|
||||
private readonly int _kSmooth;
|
||||
private readonly int _dSmooth;
|
||||
private readonly bool _blau;
|
||||
private readonly double _a1; // EMA alpha for kSmooth
|
||||
private readonly double _d1; // 1 − _a1
|
||||
private readonly double _a3; // EMA alpha for dSmooth
|
||||
private readonly double _d3; // 1 − _a3
|
||||
|
||||
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(
|
||||
// Blau path
|
||||
double Ema1, double Ema2,
|
||||
// Chande/Kroll path (numerator + denominator separate EMAs)
|
||||
double NumEma1, double NumEma2, double DenEma1, double DenEma2,
|
||||
// Signal EMA
|
||||
double Ema3,
|
||||
// Warmup compensators
|
||||
double E1, double E2, double E3,
|
||||
bool Warmup,
|
||||
// Last valid inputs
|
||||
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 >= _kPeriod;
|
||||
|
||||
public event TValuePublishedHandler? Pub;
|
||||
|
||||
public Smi(int kPeriod = 10, int kSmooth = 3, int dSmooth = 3, bool blau = true)
|
||||
{
|
||||
if (kPeriod <= 0)
|
||||
{
|
||||
throw new ArgumentException("kPeriod must be greater than 0", nameof(kPeriod));
|
||||
}
|
||||
if (kSmooth <= 0)
|
||||
{
|
||||
throw new ArgumentException("kSmooth must be greater than 0", nameof(kSmooth));
|
||||
}
|
||||
if (dSmooth <= 0)
|
||||
{
|
||||
throw new ArgumentException("dSmooth must be greater than 0", nameof(dSmooth));
|
||||
}
|
||||
|
||||
_kPeriod = kPeriod;
|
||||
_kSmooth = kSmooth;
|
||||
_dSmooth = dSmooth;
|
||||
_blau = blau;
|
||||
_a1 = 2.0 / (_kSmooth + 1);
|
||||
_d1 = 1.0 - _a1;
|
||||
_a3 = 2.0 / (_dSmooth + 1);
|
||||
_d3 = 1.0 - _a3;
|
||||
|
||||
_hBuf = new double[_kPeriod];
|
||||
_lBuf = new double[_kPeriod];
|
||||
_maxDeque = new MonotonicDeque(_kPeriod);
|
||||
_minDeque = new MonotonicDeque(_kPeriod);
|
||||
_count = 0;
|
||||
_index = -1;
|
||||
|
||||
_s = new State(0, 0, 0, 0, 0, 0, 0, 1, 1, 1, true,
|
||||
double.NaN, double.NaN, double.NaN);
|
||||
_ps = _s;
|
||||
|
||||
Name = $"Smi({kPeriod},{kSmooth},{dSmooth})";
|
||||
WarmupPeriod = kPeriod + kSmooth + dSmooth;
|
||||
_barHandler = HandleBar;
|
||||
}
|
||||
|
||||
public Smi(TBarSeries source, int kPeriod = 10, int kSmooth = 3, int dSmooth = 3, bool blau = true)
|
||||
: this(kPeriod, kSmooth, dSmooth, blau)
|
||||
{
|
||||
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 < _kPeriod)
|
||||
{
|
||||
_count++;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_s = _ps;
|
||||
}
|
||||
|
||||
var s = _s;
|
||||
|
||||
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 (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 % _kPeriod);
|
||||
_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 midpoint = (highest + lowest) * 0.5;
|
||||
double rangeHalf = (highest - lowest) * 0.5;
|
||||
|
||||
double kValue;
|
||||
|
||||
if (_blau)
|
||||
{
|
||||
double rawSmi = rangeHalf > 0.0 ? 100.0 * (close - midpoint) / rangeHalf : 0.0;
|
||||
|
||||
// Double EMA smoothing on raw ratio
|
||||
s.Ema1 = Math.FusedMultiplyAdd(s.Ema1, _d1, _a1 * rawSmi);
|
||||
double firstEma;
|
||||
|
||||
if (s.Warmup)
|
||||
{
|
||||
s.E1 *= _d1;
|
||||
s.E2 *= _d1;
|
||||
s.E3 *= _d3;
|
||||
double c1 = 1.0 / (1.0 - s.E1);
|
||||
double c2 = 1.0 / (1.0 - s.E2);
|
||||
double c3 = 1.0 / (1.0 - s.E3);
|
||||
|
||||
firstEma = s.Ema1 * c1;
|
||||
s.Ema2 = Math.FusedMultiplyAdd(s.Ema2, _d1, _a1 * firstEma);
|
||||
kValue = s.Ema2 * c2;
|
||||
s.Ema3 = Math.FusedMultiplyAdd(s.Ema3, _d3, _a3 * kValue);
|
||||
double dValue = s.Ema3 * c3;
|
||||
|
||||
s.Warmup = Math.Max(Math.Max(s.E1, s.E2), s.E3) > 1e-10;
|
||||
|
||||
_s = s;
|
||||
K = new TValue(input.Time, kValue);
|
||||
D = new TValue(input.Time, dValue);
|
||||
Last = K;
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
firstEma = s.Ema1;
|
||||
s.Ema2 = Math.FusedMultiplyAdd(s.Ema2, _d1, _a1 * firstEma);
|
||||
kValue = s.Ema2;
|
||||
s.Ema3 = Math.FusedMultiplyAdd(s.Ema3, _d3, _a3 * kValue);
|
||||
|
||||
_s = s;
|
||||
K = new TValue(input.Time, kValue);
|
||||
D = new TValue(input.Time, s.Ema3);
|
||||
Last = K;
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
// Chande/Kroll: smooth numerator and denominator separately
|
||||
double numerator = close - midpoint;
|
||||
double denominator = rangeHalf;
|
||||
|
||||
// First EMA layer
|
||||
s.NumEma1 = Math.FusedMultiplyAdd(s.NumEma1, _d1, _a1 * numerator);
|
||||
s.DenEma1 = Math.FusedMultiplyAdd(s.DenEma1, _d1, _a1 * denominator);
|
||||
|
||||
if (s.Warmup)
|
||||
{
|
||||
s.E1 *= _d1;
|
||||
s.E2 *= _d1;
|
||||
s.E3 *= _d3;
|
||||
double c1 = 1.0 / (1.0 - s.E1);
|
||||
double c2 = 1.0 / (1.0 - s.E2);
|
||||
double c3 = 1.0 / (1.0 - s.E3);
|
||||
|
||||
double numFirst = s.NumEma1 * c1;
|
||||
double denFirst = s.DenEma1 * c1;
|
||||
|
||||
// Second EMA layer
|
||||
s.NumEma2 = Math.FusedMultiplyAdd(s.NumEma2, _d1, _a1 * numFirst);
|
||||
s.DenEma2 = Math.FusedMultiplyAdd(s.DenEma2, _d1, _a1 * denFirst);
|
||||
|
||||
double smoothNum = s.NumEma2 * c2;
|
||||
double smoothDen = s.DenEma2 * c2;
|
||||
|
||||
kValue = smoothDen > 0.0 ? 100.0 * smoothNum / smoothDen : 0.0;
|
||||
|
||||
s.Ema3 = Math.FusedMultiplyAdd(s.Ema3, _d3, _a3 * kValue);
|
||||
double dVal = s.Ema3 * c3;
|
||||
|
||||
s.Warmup = Math.Max(Math.Max(s.E1, s.E2), s.E3) > 1e-10;
|
||||
|
||||
_s = s;
|
||||
K = new TValue(input.Time, kValue);
|
||||
D = new TValue(input.Time, dVal);
|
||||
Last = K;
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
double numF = s.NumEma1;
|
||||
double denF = s.DenEma1;
|
||||
|
||||
s.NumEma2 = Math.FusedMultiplyAdd(s.NumEma2, _d1, _a1 * numF);
|
||||
s.DenEma2 = Math.FusedMultiplyAdd(s.DenEma2, _d1, _a1 * denF);
|
||||
|
||||
kValue = s.DenEma2 > 0.0 ? 100.0 * s.NumEma2 / s.DenEma2 : 0.0;
|
||||
|
||||
s.Ema3 = Math.FusedMultiplyAdd(s.Ema3, _d3, _a3 * kValue);
|
||||
|
||||
_s = s;
|
||||
K = new TValue(input.Time, kValue);
|
||||
D = new TValue(input.Time, s.Ema3);
|
||||
Last = K;
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TValue input, bool isNew = true)
|
||||
{
|
||||
double val = input.Value;
|
||||
return Update(new TBar(input.Time, val, val, val, val, 0), isNew);
|
||||
}
|
||||
|
||||
public (TSeries K, TSeries D) Update(TBarSeries source)
|
||||
{
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return (new TSeries([], []), new TSeries([], []));
|
||||
}
|
||||
|
||||
int len = source.Count;
|
||||
var kArr = new double[len];
|
||||
var dArr = new double[len];
|
||||
|
||||
Batch(source.High.Values, source.Low.Values, source.Close.Values,
|
||||
kArr, dArr, _kPeriod, _kSmooth, _dSmooth, _blau);
|
||||
|
||||
var tK = new List<long>(len);
|
||||
var vK = new List<double>(len);
|
||||
var tD = new List<long>(len);
|
||||
var vD = new List<double>(len);
|
||||
|
||||
CollectionsMarshal.SetCount(tK, len);
|
||||
CollectionsMarshal.SetCount(vK, len);
|
||||
CollectionsMarshal.SetCount(tD, len);
|
||||
CollectionsMarshal.SetCount(vD, len);
|
||||
|
||||
source.Open.Times.CopyTo(CollectionsMarshal.AsSpan(tK));
|
||||
CollectionsMarshal.AsSpan(tK).CopyTo(CollectionsMarshal.AsSpan(tD));
|
||||
kArr.AsSpan().CopyTo(CollectionsMarshal.AsSpan(vK));
|
||||
dArr.AsSpan().CopyTo(CollectionsMarshal.AsSpan(vD));
|
||||
|
||||
// Restore streaming state by replaying
|
||||
Reset();
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
Update(source[i], isNew: true);
|
||||
}
|
||||
|
||||
return (new TSeries(tK, vK), new TSeries(tD, vD));
|
||||
}
|
||||
|
||||
public void Prime(TBarSeries source)
|
||||
{
|
||||
Reset();
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Update(source[i], isNew: true);
|
||||
}
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
Array.Clear(_hBuf);
|
||||
Array.Clear(_lBuf);
|
||||
_maxDeque.Reset();
|
||||
_minDeque.Reset();
|
||||
_count = 0;
|
||||
_index = -1;
|
||||
_s = new State(0, 0, 0, 0, 0, 0, 0, 1, 1, 1, true,
|
||||
double.NaN, double.NaN, double.NaN);
|
||||
_ps = _s;
|
||||
Last = default;
|
||||
K = default;
|
||||
D = default;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public static void Batch(
|
||||
ReadOnlySpan<double> high,
|
||||
ReadOnlySpan<double> low,
|
||||
ReadOnlySpan<double> close,
|
||||
Span<double> kOut,
|
||||
Span<double> dOut,
|
||||
int kPeriod = 10,
|
||||
int kSmooth = 3,
|
||||
int dSmooth = 3,
|
||||
bool blau = true)
|
||||
{
|
||||
if (kPeriod <= 0)
|
||||
{
|
||||
throw new ArgumentException("kPeriod must be greater than 0", nameof(kPeriod));
|
||||
}
|
||||
if (kSmooth <= 0)
|
||||
{
|
||||
throw new ArgumentException("kSmooth must be greater than 0", nameof(kSmooth));
|
||||
}
|
||||
if (dSmooth <= 0)
|
||||
{
|
||||
throw new ArgumentException("dSmooth must be greater than 0", nameof(dSmooth));
|
||||
}
|
||||
if (high.Length != low.Length || high.Length != close.Length)
|
||||
{
|
||||
throw new ArgumentException("Input spans must have the same length", nameof(high));
|
||||
}
|
||||
if (kOut.Length < high.Length)
|
||||
{
|
||||
throw new ArgumentException("K output span must be at least as long as input", nameof(kOut));
|
||||
}
|
||||
if (dOut.Length < high.Length)
|
||||
{
|
||||
throw new ArgumentException("D output span must be at least as long as input", nameof(dOut));
|
||||
}
|
||||
|
||||
int len = high.Length;
|
||||
if (len == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double a1 = 2.0 / (kSmooth + 1);
|
||||
double d1 = 1.0 - a1;
|
||||
double a3 = 2.0 / (dSmooth + 1);
|
||||
double d3 = 1.0 - a3;
|
||||
|
||||
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 = ArrayPool<double>.Shared.Rent(len);
|
||||
rentedLower = ArrayPool<double>.Shared.Rent(len);
|
||||
upperBuf = rentedUpper.AsSpan(0, len);
|
||||
lowerBuf = rentedLower.AsSpan(0, len);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Highest.Batch(high, upperBuf, kPeriod);
|
||||
Lowest.Batch(low, lowerBuf, kPeriod);
|
||||
|
||||
if (blau)
|
||||
{
|
||||
BatchBlau(close, upperBuf, lowerBuf, kOut, dOut, len, a1, d1, a3, d3);
|
||||
}
|
||||
else
|
||||
{
|
||||
BatchChandeKroll(close, upperBuf, lowerBuf, kOut, dOut, len, a1, d1, a3, d3);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (rentedUpper != null)
|
||||
{
|
||||
ArrayPool<double>.Shared.Return(rentedUpper);
|
||||
}
|
||||
if (rentedLower != null)
|
||||
{
|
||||
ArrayPool<double>.Shared.Return(rentedLower);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static (TSeries K, TSeries D) Batch(TBarSeries source,
|
||||
int kPeriod = 10, int kSmooth = 3, int dSmooth = 3, bool blau = true)
|
||||
{
|
||||
if (source.Count == 0)
|
||||
{
|
||||
return (new TSeries([], []), new TSeries([], []));
|
||||
}
|
||||
|
||||
int len = source.Count;
|
||||
var kArr = new double[len];
|
||||
var dArr = new double[len];
|
||||
|
||||
Batch(source.High.Values, source.Low.Values, source.Close.Values,
|
||||
kArr, dArr, kPeriod, kSmooth, dSmooth, blau);
|
||||
|
||||
var tK = new List<long>(len);
|
||||
var vK = new List<double>(len);
|
||||
var tD = new List<long>(len);
|
||||
var vD = new List<double>(len);
|
||||
|
||||
CollectionsMarshal.SetCount(tK, len);
|
||||
CollectionsMarshal.SetCount(vK, len);
|
||||
CollectionsMarshal.SetCount(tD, len);
|
||||
CollectionsMarshal.SetCount(vD, len);
|
||||
|
||||
source.Open.Times.CopyTo(CollectionsMarshal.AsSpan(tK));
|
||||
CollectionsMarshal.AsSpan(tK).CopyTo(CollectionsMarshal.AsSpan(tD));
|
||||
kArr.AsSpan().CopyTo(CollectionsMarshal.AsSpan(vK));
|
||||
dArr.AsSpan().CopyTo(CollectionsMarshal.AsSpan(vD));
|
||||
|
||||
return (new TSeries(tK, vK), new TSeries(tD, vD));
|
||||
}
|
||||
|
||||
public static ((TSeries K, TSeries D) Results, Smi Indicator) Calculate(
|
||||
TBarSeries source, int kPeriod = 10, int kSmooth = 3, int dSmooth = 3, bool blau = true)
|
||||
{
|
||||
var indicator = new Smi(kPeriod, kSmooth, dSmooth, blau);
|
||||
var results = indicator.Update(source);
|
||||
return (results, indicator);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void BatchBlau(
|
||||
ReadOnlySpan<double> close,
|
||||
ReadOnlySpan<double> highest,
|
||||
ReadOnlySpan<double> lowest,
|
||||
Span<double> kOut,
|
||||
Span<double> dOut,
|
||||
int len,
|
||||
double a1, double d1, double a3, double d3)
|
||||
{
|
||||
double ema1 = 0, ema2 = 0, ema3 = 0;
|
||||
double e1 = 1, e2 = 1, e3 = 1;
|
||||
bool warmup = true;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
double mid = (highest[i] + lowest[i]) * 0.5;
|
||||
double rh = (highest[i] - lowest[i]) * 0.5;
|
||||
double raw = rh > 0 ? 100.0 * (close[i] - mid) / rh : 0.0;
|
||||
|
||||
ema1 = Math.FusedMultiplyAdd(ema1, d1, a1 * raw);
|
||||
|
||||
double k;
|
||||
double d;
|
||||
if (warmup)
|
||||
{
|
||||
e1 *= d1;
|
||||
e2 *= d1;
|
||||
e3 *= d3;
|
||||
double c1 = 1.0 / (1.0 - e1);
|
||||
double c2 = 1.0 / (1.0 - e2);
|
||||
double c3 = 1.0 / (1.0 - e3);
|
||||
|
||||
double f = ema1 * c1;
|
||||
ema2 = Math.FusedMultiplyAdd(ema2, d1, a1 * f);
|
||||
k = ema2 * c2;
|
||||
ema3 = Math.FusedMultiplyAdd(ema3, d3, a3 * k);
|
||||
d = ema3 * c3;
|
||||
warmup = Math.Max(Math.Max(e1, e2), e3) > 1e-10;
|
||||
}
|
||||
else
|
||||
{
|
||||
ema2 = Math.FusedMultiplyAdd(ema2, d1, a1 * ema1);
|
||||
k = ema2;
|
||||
ema3 = Math.FusedMultiplyAdd(ema3, d3, a3 * k);
|
||||
d = ema3;
|
||||
}
|
||||
|
||||
kOut[i] = k;
|
||||
dOut[i] = d;
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void BatchChandeKroll(
|
||||
ReadOnlySpan<double> close,
|
||||
ReadOnlySpan<double> highest,
|
||||
ReadOnlySpan<double> lowest,
|
||||
Span<double> kOut,
|
||||
Span<double> dOut,
|
||||
int len,
|
||||
double a1, double d1, double a3, double d3)
|
||||
{
|
||||
double numEma1 = 0, numEma2 = 0, denEma1 = 0, denEma2 = 0, ema3 = 0;
|
||||
double e1 = 1, e2 = 1, e3 = 1;
|
||||
bool warmup = true;
|
||||
|
||||
for (int i = 0; i < len; i++)
|
||||
{
|
||||
double mid = (highest[i] + lowest[i]) * 0.5;
|
||||
double rh = (highest[i] - lowest[i]) * 0.5;
|
||||
double num = close[i] - mid;
|
||||
double den = rh;
|
||||
|
||||
numEma1 = Math.FusedMultiplyAdd(numEma1, d1, a1 * num);
|
||||
denEma1 = Math.FusedMultiplyAdd(denEma1, d1, a1 * den);
|
||||
|
||||
double k;
|
||||
double d;
|
||||
if (warmup)
|
||||
{
|
||||
e1 *= d1;
|
||||
e2 *= d1;
|
||||
e3 *= d3;
|
||||
double c1 = 1.0 / (1.0 - e1);
|
||||
double c2 = 1.0 / (1.0 - e2);
|
||||
double c3 = 1.0 / (1.0 - e3);
|
||||
|
||||
double nf = numEma1 * c1;
|
||||
double df = denEma1 * c1;
|
||||
numEma2 = Math.FusedMultiplyAdd(numEma2, d1, a1 * nf);
|
||||
denEma2 = Math.FusedMultiplyAdd(denEma2, d1, a1 * df);
|
||||
double sn = numEma2 * c2;
|
||||
double sd = denEma2 * c2;
|
||||
k = sd > 0 ? 100.0 * sn / sd : 0.0;
|
||||
ema3 = Math.FusedMultiplyAdd(ema3, d3, a3 * k);
|
||||
d = ema3 * c3;
|
||||
warmup = Math.Max(Math.Max(e1, e2), e3) > 1e-10;
|
||||
}
|
||||
else
|
||||
{
|
||||
numEma2 = Math.FusedMultiplyAdd(numEma2, d1, a1 * numEma1);
|
||||
denEma2 = Math.FusedMultiplyAdd(denEma2, d1, a1 * denEma1);
|
||||
k = denEma2 > 0 ? 100.0 * numEma2 / denEma2 : 0.0;
|
||||
ema3 = Math.FusedMultiplyAdd(ema3, d3, a3 * k);
|
||||
d = ema3;
|
||||
}
|
||||
|
||||
kOut[i] = k;
|
||||
dOut[i] = d;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
# SMI: Stochastic Momentum Index
|
||||
|
||||
> "The stochastic tells you where price is in the range. The SMI tells you how enthusiastically it got there." — William Blau (paraphrased)
|
||||
|
||||
## Introduction
|
||||
|
||||
The Stochastic Momentum Index (SMI) measures where the close sits relative to the midpoint of the recent high-low range, then double-smooths the result with cascaded EMAs. Unlike the classic Stochastic Oscillator which measures distance from the low, SMI measures distance from the midpoint. This centering around zero produces cleaner crossover signals and reduces false readings during trending markets. Range: -100 to +100, with values beyond ±40 indicating extreme momentum.
|
||||
|
||||
## Historical Context
|
||||
|
||||
William Blau introduced the SMI in his 1995 book "Momentum, Direction, and Divergence" as an improvement over George Lane's classic Stochastic Oscillator. Blau's key insight: measuring distance from the range midpoint rather than from the low eliminates the asymmetric bias that plagues traditional stochastics. When price closes at the exact middle of its range, classic Stochastic reads 50 — an arbitrary number that says nothing. SMI reads 0 — neutral, centered, semantically honest.
|
||||
|
||||
Tushar Chande and Stanley Kroll proposed a variant in "The New Technical Trader" (1994) that smooths numerator and denominator separately before computing the ratio. This subtle difference in order of operations produces different behavior during volatile periods: Blau's method smooths the ratio directly, which can compress extreme values; Chande/Kroll's method preserves the ratio's sensitivity by smoothing its components independently.
|
||||
|
||||
QuanTAlib implements both methods via the `blau` parameter (default: `true` for Blau's method).
|
||||
|
||||
## Architecture and Physics
|
||||
|
||||
### 1. Rolling Highest High and Lowest Low
|
||||
|
||||
Using O(1) amortized MonotonicDeque for the kPeriod window:
|
||||
|
||||
$$HH_t = \max_{i=0}^{N-1} \text{High}_{t-i}$$
|
||||
|
||||
$$LL_t = \min_{i=0}^{N-1} \text{Low}_{t-i}$$
|
||||
|
||||
### 2. Midpoint and Half-Range
|
||||
|
||||
$$\text{midpoint}_t = \frac{HH_t + LL_t}{2}$$
|
||||
|
||||
$$\text{rangeHalf}_t = \frac{HH_t - LL_t}{2}$$
|
||||
|
||||
### 3. Blau Method (Default)
|
||||
|
||||
Compute the raw ratio first, then double-smooth:
|
||||
|
||||
$$\text{raw}_t = \begin{cases} 100 \times \frac{\text{Close}_t - \text{midpoint}_t}{\text{rangeHalf}_t} & \text{if } \text{rangeHalf}_t > 0 \\ 0 & \text{otherwise} \end{cases}$$
|
||||
|
||||
$$K_t = \text{EMA}_2(\text{EMA}_1(\text{raw}_t, \text{kSmooth}), \text{kSmooth})$$
|
||||
|
||||
$$D_t = \text{EMA}(K_t, \text{dSmooth})$$
|
||||
|
||||
### 4. Chande/Kroll Method
|
||||
|
||||
Smooth numerator and denominator separately, then compute the ratio:
|
||||
|
||||
$$\text{num}_t = \text{Close}_t - \text{midpoint}_t$$
|
||||
|
||||
$$\text{den}_t = \text{rangeHalf}_t$$
|
||||
|
||||
$$K_t = 100 \times \frac{\text{EMA}_2(\text{EMA}_1(\text{num}))}{\text{EMA}_2(\text{EMA}_1(\text{den}))}$$
|
||||
|
||||
$$D_t = \text{EMA}(K_t, \text{dSmooth})$$
|
||||
|
||||
### 5. EMA with Warmup Compensation
|
||||
|
||||
Each EMA stage uses exponential warmup compensation:
|
||||
|
||||
$$\alpha = \frac{2}{N + 1}, \quad d = 1 - \alpha$$
|
||||
|
||||
$$\text{EMA}_t = d \cdot \text{EMA}_{t-1} + \alpha \cdot x_t$$
|
||||
|
||||
$$e_t = d \cdot e_{t-1}, \quad c_t = \frac{1}{1 - e_t}$$
|
||||
|
||||
$$\text{compensated}_t = \text{EMA}_t \cdot c_t$$
|
||||
|
||||
The compensator corrects the initialization bias during warmup, converging to 1.0 as $e_t \to 0$.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
### Blau's Z-Domain Transfer Function
|
||||
|
||||
The double-EMA smoothing of the raw ratio has transfer function:
|
||||
|
||||
$$H(z) = \left(\frac{\alpha}{1 - dz^{-1}}\right)^2$$
|
||||
|
||||
This is a cascade of two identical first-order IIR sections, providing $-12$ dB/octave rolloff in the stopband. The cascade attenuates noise more aggressively than a single EMA of equivalent period, at the cost of additional group delay.
|
||||
|
||||
### Chande/Kroll Ratio Properties
|
||||
|
||||
The separate smoothing approach preserves a fundamental property: when numerator and denominator oscillate at the same frequency, their ratio remains unattenuated. Blau's method, by smoothing the ratio directly, can compress oscillations that the Chande/Kroll approach preserves.
|
||||
|
||||
### Parameter Mapping
|
||||
|
||||
| Parameter | Default | Range | Effect |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| kPeriod ($N$) | 10 | 1-500 | Lookback window for highest/lowest. Larger values produce a wider reference range, reducing sensitivity. |
|
||||
| kSmooth | 3 | 1-100 | EMA period for the double-smoothing of K. Larger values smooth more aggressively, increasing lag. |
|
||||
| dSmooth | 3 | 1-100 | EMA period for the signal line D. Controls signal line responsiveness. |
|
||||
| blau | true | bool | `true` for Blau method (smooth ratio); `false` for Chande/Kroll (smooth components). |
|
||||
|
||||
### Warmup Period
|
||||
|
||||
$$\text{WarmupPeriod} = \text{kPeriod} + \text{kSmooth} + \text{dSmooth}$$
|
||||
|
||||
The indicator becomes `IsHot` after `kPeriod` bars (sufficient for the deque window). Full convergence of all three EMA stages requires the full warmup period.
|
||||
|
||||
## Performance Profile
|
||||
|
||||
| Operation | Complexity | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| MonotonicDeque push | O(1) amortized | Deque maintenance for highest/lowest |
|
||||
| Midpoint/rangeHalf | O(1) | Two arithmetic operations |
|
||||
| EMA stage 1 | O(1) | FMA-optimized |
|
||||
| EMA stage 2 | O(1) | FMA-optimized |
|
||||
| Signal EMA | O(1) | FMA-optimized |
|
||||
| **Total per bar** | **O(1)** | Zero allocations in hot path |
|
||||
|
||||
### SIMD Analysis
|
||||
|
||||
SIMD is not applied in streaming `Update` due to the recursive EMA dependencies. The `Batch` span API delegates highest/lowest computation to their respective SIMD-enabled `Batch` methods, then processes the EMA cascade sequentially.
|
||||
|
||||
### Quality Metrics
|
||||
|
||||
| Metric | Score (1-10) | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| Noise rejection | 7 | Double EMA smoothing provides good noise attenuation |
|
||||
| Lag | 5 | Three cascaded EMA stages accumulate group delay |
|
||||
| Sensitivity | 8 | Midpoint centering produces sharper zero crossings than classic Stochastic |
|
||||
| Range bound | 9 | Naturally bounded -100 to +100 by construction |
|
||||
| Cross-instrument | 8 | Percentage-based output is comparable across instruments |
|
||||
|
||||
## Interpretation
|
||||
|
||||
### Overbought/Oversold Levels
|
||||
|
||||
- **Above +40**: Overbought zone. Close is significantly above the range midpoint. Reversal probability increases.
|
||||
- **Below -40**: Oversold zone. Close is significantly below the range midpoint. Bounce probability increases.
|
||||
- **Between -20 and +20**: Neutral zone. No strong momentum bias.
|
||||
|
||||
### K and D Crossovers
|
||||
|
||||
- **K crosses above D**: Bullish momentum shift. Momentum is accelerating upward.
|
||||
- **K crosses below D**: Bearish momentum shift. Momentum is decelerating or reversing.
|
||||
|
||||
### Divergence Analysis
|
||||
|
||||
- **Bullish divergence**: Price makes lower lows while SMI K makes higher lows. Range-normalized momentum is contracting despite new price lows.
|
||||
- **Bearish divergence**: Price makes higher highs while SMI K makes lower highs. Despite new highs, momentum relative to range is weakening.
|
||||
|
||||
### Blau vs Chande/Kroll Selection
|
||||
|
||||
- **Blau (default)**: Better for trend-following. Smoother output, fewer whipsaws. The ratio compression during high volatility acts as a natural dampener.
|
||||
- **Chande/Kroll**: Better for mean-reversion. Preserves component oscillation sensitivity. More responsive during volatile reversals but noisier in trends.
|
||||
|
||||
## Validation
|
||||
|
||||
| Library | Validated | Notes |
|
||||
| :--- | :--- | :--- |
|
||||
| Skender | ✔️ | SMI available via `GetSmi()` |
|
||||
| TA-Lib | - | No SMI implementation |
|
||||
| Tulip | - | No SMI implementation |
|
||||
| Ooples | - | Not verified |
|
||||
| Self-consistency | ✔️ | Batch/streaming/span agree within $10^{-6}$ tolerance |
|
||||
|
||||
Cross-validation: Streaming, batch (TBarSeries), and span paths produce identical results. Both Blau and Chande/Kroll methods are verified independently.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Confusing SMI with classic Stochastic**: SMI measures distance from midpoint (range: -100 to +100). Classic Stochastic measures distance from the low (range: 0 to 100). Using Stochastic thresholds (20/80) for SMI produces incorrect signals.
|
||||
2. **Ignoring the method parameter**: Blau and Chande/Kroll produce meaningfully different results. Switching methods mid-analysis invalidates comparisons.
|
||||
3. **Zero range handling**: When highest equals lowest (constant price over kPeriod), rangeHalf is zero. Division by zero is guarded (returns 0.0), but a sustained zero reading may mask meaningful price action outside the deque window.
|
||||
4. **Cascaded EMA warmup**: Three EMA stages each need convergence time. The first few values after `IsHot` are less reliable than values after the full `WarmupPeriod`. Trading signals should wait for full convergence.
|
||||
5. **Period selection interaction**: kPeriod controls the reference range width; kSmooth controls noise filtering of K; dSmooth controls signal line lag. These three parameters interact. Increasing kPeriod without adjusting smoothing produces a wider range reference with insufficient filtering, yielding noisy K values.
|
||||
6. **Not a standalone signal**: SMI measures momentum position within a range. Combine with trend filters for directional context. SMI works best in ranging markets; in strong trends, it can remain in overbought/oversold territory for extended periods.
|
||||
7. **Bar correction with MonotonicDeque**: The `isNew=false` path rebuilds the deque from the circular buffer. Frequent corrections (high-frequency bar updates) are supported but carry O(N) rebuild cost per correction, where N is kPeriod.
|
||||
|
||||
## References
|
||||
|
||||
- Blau, William. "Momentum, Direction, and Divergence." Wiley, 1995.
|
||||
- Chande, Tushar S. and Kroll, Stanley. "The New Technical Trader." Wiley, 1994.
|
||||
- Lane, George. "Stochastics." Technical Analysis of Stocks & Commodities, 1984. (Original Stochastic Oscillator)
|
||||
- PineScript reference implementation: `smi.pine`
|
||||
Reference in New Issue
Block a user