mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-23 13:08:04 +00:00
Add Standard Deviation Channel (SDCHANNEL) implementation and documentation
- Implemented Sdchannel class for calculating standard deviation channels based on linear regression. - Added detailed documentation for SDCHANNEL, including overview, calculation methods, and interpretation. - Updated project files to include new numerics library components in Channels and Volatility projects.
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class KchannelIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_SetsDefaults()
|
||||
{
|
||||
var ind = new KchannelIndicator();
|
||||
|
||||
Assert.Equal(20, ind.Period);
|
||||
Assert.Equal(2.0, ind.Multiplier);
|
||||
Assert.True(ind.ShowColdValues);
|
||||
Assert.Equal("Kchannel - Keltner Channel", ind.Name);
|
||||
Assert.False(ind.SeparateWindow);
|
||||
Assert.True(ind.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinHistoryDepths_EqualsPeriodTimesTwo()
|
||||
{
|
||||
var ind = new KchannelIndicator { Period = 15 };
|
||||
Assert.Equal(30, ind.MinHistoryDepths); // Period * 2
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShortName_ReflectsParameters()
|
||||
{
|
||||
var ind = new KchannelIndicator { Period = 12, Multiplier = 1.5 };
|
||||
Assert.Contains("12", ind.ShortName, StringComparison.Ordinal);
|
||||
Assert.Contains("1.5", ind.ShortName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_AddsThreeLineSeries()
|
||||
{
|
||||
var ind = new KchannelIndicator { Period = 14, Multiplier = 2.0 };
|
||||
ind.Initialize();
|
||||
|
||||
Assert.Equal(3, ind.LinesSeries.Count);
|
||||
Assert.Equal("Middle", ind.LinesSeries[0].Name);
|
||||
Assert.Equal("Upper", ind.LinesSeries[1].Name);
|
||||
Assert.Equal("Lower", ind.LinesSeries[2].Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessUpdate_Historical_ComputesValues()
|
||||
{
|
||||
var ind = new KchannelIndicator { Period = 3, Multiplier = 2.0 };
|
||||
ind.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
ind.HistoricalData.AddBar(now, 100, 110, 90, 102);
|
||||
|
||||
ind.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
Assert.Equal(1, ind.LinesSeries[0].Count);
|
||||
Assert.True(double.IsFinite(ind.LinesSeries[0].GetValue(0)));
|
||||
Assert.True(double.IsFinite(ind.LinesSeries[1].GetValue(0)));
|
||||
Assert.True(double.IsFinite(ind.LinesSeries[2].GetValue(0)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessUpdate_NewBar_Appends()
|
||||
{
|
||||
var ind = new KchannelIndicator { Period = 3, Multiplier = 2.0 };
|
||||
ind.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
ind.HistoricalData.AddBar(now, 100, 110, 90, 102);
|
||||
ind.HistoricalData.AddBar(now.AddMinutes(1), 102, 112, 92, 104);
|
||||
|
||||
ind.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
ind.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
|
||||
Assert.Equal(2, ind.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessUpdate_NewTick_DoesNotThrow()
|
||||
{
|
||||
var ind = new KchannelIndicator { Period = 5, Multiplier = 2.0 };
|
||||
ind.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
ind.HistoricalData.AddBar(now, 100, 105, 95, 102);
|
||||
|
||||
ind.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
ind.ProcessUpdate(new UpdateArgs(UpdateReason.NewTick));
|
||||
|
||||
Assert.Equal(2, ind.LinesSeries[0].Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultipleUpdates_ProducesFiniteSeries()
|
||||
{
|
||||
var ind = new KchannelIndicator { Period = 5, Multiplier = 2.0 };
|
||||
ind.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
ind.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 105 + i, 95 + i, 102 + i);
|
||||
ind.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
|
||||
}
|
||||
|
||||
Assert.Equal(20, ind.LinesSeries[0].Count);
|
||||
Assert.Equal(20, ind.LinesSeries[1].Count);
|
||||
Assert.Equal(20, ind.LinesSeries[2].Count);
|
||||
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(ind.LinesSeries[0].GetValue(i)));
|
||||
Assert.True(double.IsFinite(ind.LinesSeries[1].GetValue(i)));
|
||||
Assert.True(double.IsFinite(ind.LinesSeries[2].GetValue(i)));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bands_Order_Correct()
|
||||
{
|
||||
var ind = new KchannelIndicator { Period = 5, Multiplier = 2.0 };
|
||||
ind.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
// Create bars with some volatility
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
ind.HistoricalData.AddBar(now.AddMinutes(i), 100, 110, 90, 100, 1000);
|
||||
ind.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
|
||||
}
|
||||
|
||||
double middle = ind.LinesSeries[0].GetValue(0);
|
||||
double upper = ind.LinesSeries[1].GetValue(0);
|
||||
double lower = ind.LinesSeries[2].GetValue(0);
|
||||
|
||||
// After warmup with volatility, upper > middle > lower
|
||||
Assert.True(upper >= middle, $"Upper ({upper}) should be >= Middle ({middle})");
|
||||
Assert.True(lower <= middle, $"Lower ({lower}) should be <= Middle ({middle})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Bands_Expand_WithVolatility()
|
||||
{
|
||||
var ind = new KchannelIndicator { Period = 5, Multiplier = 2.0 };
|
||||
ind.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// First few bars: low volatility
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
ind.HistoricalData.AddBar(now.AddMinutes(i), 100, 101, 99, 100);
|
||||
ind.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
|
||||
}
|
||||
|
||||
double lowVolWidth = ind.LinesSeries[1].GetValue(0) - ind.LinesSeries[2].GetValue(0);
|
||||
|
||||
// Next bars: high volatility
|
||||
for (int i = 5; i < 15; i++)
|
||||
{
|
||||
ind.HistoricalData.AddBar(now.AddMinutes(i), 100, 120, 80, 100);
|
||||
ind.ProcessUpdate(new UpdateArgs(UpdateReason.NewBar));
|
||||
}
|
||||
|
||||
double highVolWidth = ind.LinesSeries[1].GetValue(0) - ind.LinesSeries[2].GetValue(0);
|
||||
|
||||
Assert.True(highVolWidth > lowVolWidth, "Higher volatility should produce wider bands");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstBar_AllBandsEqualClose()
|
||||
{
|
||||
var ind = new KchannelIndicator { Period = 10, Multiplier = 2.0 };
|
||||
ind.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
ind.HistoricalData.AddBar(now, 100, 110, 90, 105);
|
||||
ind.ProcessUpdate(new UpdateArgs(UpdateReason.HistoricalBar));
|
||||
|
||||
double middle = ind.LinesSeries[0].GetValue(0);
|
||||
double upper = ind.LinesSeries[1].GetValue(0);
|
||||
double lower = ind.LinesSeries[2].GetValue(0);
|
||||
|
||||
// First bar: all equal close (no ATR yet)
|
||||
Assert.Equal(105.0, middle, 1e-10);
|
||||
Assert.Equal(105.0, upper, 1e-10);
|
||||
Assert.Equal(105.0, lower, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multiplier_AffectsBandWidth()
|
||||
{
|
||||
var ind1 = new KchannelIndicator { Period = 10, Multiplier = 1.0 };
|
||||
var ind2 = new KchannelIndicator { Period = 10, Multiplier = 2.0 };
|
||||
ind1.Initialize();
|
||||
ind2.Initialize();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
ind1.HistoricalData.AddBar(now.AddMinutes(i), 100, 110, 90, 100);
|
||||
ind2.HistoricalData.AddBar(now.AddMinutes(i), 100, 110, 90, 100);
|
||||
ind1.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
|
||||
ind2.ProcessUpdate(new UpdateArgs(i == 0 ? UpdateReason.HistoricalBar : UpdateReason.NewBar));
|
||||
}
|
||||
|
||||
double width1 = ind1.LinesSeries[1].GetValue(0) - ind1.LinesSeries[2].GetValue(0);
|
||||
double width2 = ind2.LinesSeries[1].GetValue(0) - ind2.LinesSeries[2].GetValue(0);
|
||||
|
||||
Assert.Equal(width2, width1 * 2, 1e-9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Drawing;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using static QuanTAlib.IndicatorExtensions;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// Kchannel: Keltner Channel - Quantower Indicator Adapter
|
||||
/// A volatility-based envelope using EMA as the middle line and ATR for band width.
|
||||
/// Middle = EMA(close, period) with warmup compensation
|
||||
/// Upper = Middle + (multiplier × ATR)
|
||||
/// Lower = Middle - (multiplier × ATR)
|
||||
/// ATR uses RMA (Wilder's smoothing) with warmup compensation.
|
||||
/// </summary>
|
||||
public sealed class KchannelIndicator : Indicator, IWatchlistIndicator
|
||||
{
|
||||
[InputParameter("Period", sortIndex: 10, minimum: 1, maximum: 500, increment: 1, decimalPlaces: 0)]
|
||||
public int Period { get; set; } = 20;
|
||||
|
||||
[InputParameter("Multiplier", sortIndex: 20, minimum: 0.1, maximum: 10.0, increment: 0.1, decimalPlaces: 1)]
|
||||
public double Multiplier { get; set; } = 2.0;
|
||||
|
||||
[InputParameter("Show Cold Values", sortIndex: 100)]
|
||||
public bool ShowColdValues { get; set; } = true;
|
||||
|
||||
private Kchannel? _indicator;
|
||||
|
||||
public int MinHistoryDepths => Period * 2;
|
||||
public override string ShortName => $"Kchannel({Period},{Multiplier})";
|
||||
|
||||
public KchannelIndicator()
|
||||
{
|
||||
Name = "Kchannel - Keltner Channel";
|
||||
Description = "EMA-based channel with ATR-derived band width";
|
||||
SeparateWindow = false;
|
||||
OnBackGround = true;
|
||||
}
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
_indicator = new Kchannel(Period, Multiplier);
|
||||
|
||||
AddLineSeries(new LineSeries("Middle", Color.DodgerBlue, 2, LineStyle.Solid));
|
||||
AddLineSeries(new LineSeries("Upper", Color.FromArgb(255, 180, 180), 1, LineStyle.Dash));
|
||||
AddLineSeries(new LineSeries("Lower", Color.FromArgb(180, 180, 255), 1, LineStyle.Dash));
|
||||
}
|
||||
|
||||
protected override void OnUpdate(UpdateArgs args)
|
||||
{
|
||||
if (_indicator is null)
|
||||
return;
|
||||
|
||||
var item = HistoricalData[0, SeekOriginHistory.End];
|
||||
bool isNew = args.IsNewBar();
|
||||
|
||||
TBar input = new(
|
||||
time: item.TimeLeft,
|
||||
open: item[PriceType.Open],
|
||||
high: item[PriceType.High],
|
||||
low: item[PriceType.Low],
|
||||
close: item[PriceType.Close],
|
||||
volume: item[PriceType.Volume]
|
||||
);
|
||||
|
||||
_indicator.Update(input, isNew);
|
||||
|
||||
bool isHot = _indicator.IsHot;
|
||||
|
||||
LinesSeries[0].SetValue(_indicator.Last.Value, isHot, ShowColdValues);
|
||||
LinesSeries[1].SetValue(_indicator.Upper.Value, isHot, ShowColdValues);
|
||||
LinesSeries[2].SetValue(_indicator.Lower.Value, isHot, ShowColdValues);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
using System;
|
||||
using QuanTAlib;
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class KchannelTests
|
||||
{
|
||||
[Fact]
|
||||
public void Kchannel_Constructor_ValidatesInput()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Kchannel(0));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Kchannel(-5));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Kchannel(10, 0.0));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Kchannel(10, -1.0));
|
||||
|
||||
var k = new Kchannel(10, 2.0);
|
||||
Assert.Equal(20, k.WarmupPeriod); // period * 2
|
||||
Assert.Contains("Kchannel", k.Name, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kchannel_InitialState_Defaults()
|
||||
{
|
||||
var k = new Kchannel(5);
|
||||
|
||||
Assert.Equal(0, k.Last.Value);
|
||||
Assert.Equal(0, k.Upper.Value);
|
||||
Assert.Equal(0, k.Lower.Value);
|
||||
Assert.False(k.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kchannel_FirstBar_AllBandsEqualClose()
|
||||
{
|
||||
var k = new Kchannel(10, 2.0);
|
||||
|
||||
var result = k.Update(new TBar(DateTime.UtcNow, 100, 105, 95, 102, 1000));
|
||||
|
||||
// First bar: EMA = close, ATR = 0, so all bands = close
|
||||
Assert.Equal(102.0, result.Value, 1e-10);
|
||||
Assert.Equal(102.0, k.Upper.Value, 1e-10);
|
||||
Assert.Equal(102.0, k.Lower.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kchannel_SecondBar_BandsExpand()
|
||||
{
|
||||
var k = new Kchannel(10, 2.0);
|
||||
|
||||
k.Update(new TBar(DateTime.UtcNow, 100, 105, 95, 100, 1000));
|
||||
|
||||
// Second bar with volatility
|
||||
_ = k.Update(new TBar(DateTime.UtcNow, 102, 110, 92, 102, 1000));
|
||||
|
||||
// EMA shifts toward 102, ATR > 0, bands expand
|
||||
Assert.True(k.Upper.Value > k.Last.Value, "Upper should be above middle");
|
||||
Assert.True(k.Lower.Value < k.Last.Value, "Lower should be below middle");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kchannel_BandWidth_ProportionalToATR()
|
||||
{
|
||||
var k1 = new Kchannel(10, 1.0);
|
||||
var k2 = new Kchannel(10, 2.0);
|
||||
var k3 = new Kchannel(10, 3.0);
|
||||
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.2, seed: 42);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
k1.Update(bar);
|
||||
k2.Update(bar);
|
||||
k3.Update(bar);
|
||||
}
|
||||
|
||||
double width1 = k1.Upper.Value - k1.Lower.Value;
|
||||
double width2 = k2.Upper.Value - k2.Lower.Value;
|
||||
double width3 = k3.Upper.Value - k3.Lower.Value;
|
||||
|
||||
// Width should scale linearly with multiplier
|
||||
Assert.Equal(width2, width1 * 2, 1e-9);
|
||||
Assert.Equal(width3, width1 * 3, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kchannel_BandOrder_Correct()
|
||||
{
|
||||
var k = new Kchannel(10, 2.0);
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.15, seed: 42);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
k.Update(bar);
|
||||
|
||||
// After first bar, upper > middle > lower
|
||||
if (i > 0)
|
||||
{
|
||||
Assert.True(k.Upper.Value > k.Last.Value, $"Upper > Middle at bar {i}");
|
||||
Assert.True(k.Lower.Value < k.Last.Value, $"Lower < Middle at bar {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kchannel_MiddleIsEMA()
|
||||
{
|
||||
var k = new Kchannel(10, 2.0);
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
var result = k.Update(bar);
|
||||
|
||||
// Middle is EMA (returned value)
|
||||
Assert.Equal(result.Value, k.Last.Value, 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kchannel_BandSymmetry()
|
||||
{
|
||||
var k = new Kchannel(10, 2.0);
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42);
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
k.Update(bar);
|
||||
|
||||
// Bands should be symmetric around middle
|
||||
double upperDist = k.Upper.Value - k.Last.Value;
|
||||
double lowerDist = k.Last.Value - k.Lower.Value;
|
||||
Assert.Equal(upperDist, lowerDist, 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kchannel_IsHot_TurnsTrueAfterWarmup()
|
||||
{
|
||||
var k = new Kchannel(5);
|
||||
// WarmupPeriod = 5 * 2 = 10
|
||||
|
||||
for (int i = 0; i < 9; i++)
|
||||
{
|
||||
k.Update(new TBar(DateTime.UtcNow, 100 + i, 101 + i, 99 + i, 100 + i, 1000));
|
||||
Assert.False(k.IsHot);
|
||||
}
|
||||
|
||||
k.Update(new TBar(DateTime.UtcNow, 200, 201, 199, 200, 1000));
|
||||
Assert.True(k.IsHot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kchannel_IsNewFalse_RebuildsState()
|
||||
{
|
||||
var k = new Kchannel(10, 2.0);
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 7);
|
||||
|
||||
TBar remembered = default;
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
remembered = gbm.Next(isNew: true);
|
||||
k.Update(remembered, isNew: true);
|
||||
}
|
||||
|
||||
double mid = k.Last.Value;
|
||||
double up = k.Upper.Value;
|
||||
double lo = k.Lower.Value;
|
||||
|
||||
// Apply corrections
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var corrected = gbm.Next(isNew: false);
|
||||
k.Update(corrected, isNew: false);
|
||||
}
|
||||
|
||||
// Restore with remembered bar
|
||||
k.Update(remembered, isNew: false);
|
||||
|
||||
Assert.Equal(mid, k.Last.Value, 1e-10);
|
||||
Assert.Equal(up, k.Upper.Value, 1e-10);
|
||||
Assert.Equal(lo, k.Lower.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kchannel_NaN_UsesLastValid()
|
||||
{
|
||||
var k = new Kchannel(10, 2.0);
|
||||
|
||||
k.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000));
|
||||
k.Update(new TBar(DateTime.UtcNow, 101, 111, 91, 106, 1000));
|
||||
|
||||
var result = k.Update(new TBar(DateTime.UtcNow, 102, double.NaN, 92, 107, 1000));
|
||||
Assert.True(double.IsFinite(result.Value));
|
||||
Assert.True(double.IsFinite(k.Upper.Value));
|
||||
Assert.True(double.IsFinite(k.Lower.Value));
|
||||
|
||||
var result2 = k.Update(new TBar(DateTime.UtcNow, 103, 113, double.PositiveInfinity, 108, 1000));
|
||||
Assert.True(double.IsFinite(result2.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kchannel_Reset_Clears()
|
||||
{
|
||||
var k = new Kchannel(10, 2.0);
|
||||
k.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000));
|
||||
k.Update(new TBar(DateTime.UtcNow, 101, 111, 91, 101, 1000));
|
||||
k.Update(new TBar(DateTime.UtcNow, 102, 112, 92, 102, 1000));
|
||||
|
||||
k.Reset();
|
||||
|
||||
Assert.Equal(0, k.Last.Value);
|
||||
Assert.Equal(0, k.Upper.Value);
|
||||
Assert.Equal(0, k.Lower.Value);
|
||||
Assert.False(k.IsHot);
|
||||
|
||||
k.Update(new TBar(DateTime.UtcNow, 50, 60, 40, 55, 1000));
|
||||
Assert.NotEqual(0, k.Last.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kchannel_BatchVsStreaming_Match()
|
||||
{
|
||||
var kStream = new Kchannel(20, 1.5);
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.02, sigma: 0.15, seed: 42);
|
||||
var series = new TBarSeries();
|
||||
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
series.Add(bar);
|
||||
kStream.Update(bar, isNew: true);
|
||||
}
|
||||
|
||||
double expectedMid = kStream.Last.Value;
|
||||
double expectedUp = kStream.Upper.Value;
|
||||
double expectedLo = kStream.Lower.Value;
|
||||
|
||||
var (midBatch, upBatch, loBatch) = Kchannel.Batch(series, 20, 1.5);
|
||||
|
||||
Assert.Equal(expectedMid, midBatch.Last.Value, 1e-10);
|
||||
Assert.Equal(expectedUp, upBatch.Last.Value, 1e-10);
|
||||
Assert.Equal(expectedLo, loBatch.Last.Value, 1e-10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kchannel_SpanBatch_Validates()
|
||||
{
|
||||
double[] high = [110, 115, 120];
|
||||
double[] low = [90, 95, 100];
|
||||
double[] close = [100, 105, 110];
|
||||
double[] middle = new double[3];
|
||||
double[] upper = new double[3];
|
||||
double[] lower = new double[3];
|
||||
|
||||
double[] highShort = [110, 115];
|
||||
double[] smallOut = new double[1];
|
||||
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => Kchannel.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), 0));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => Kchannel.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), -1));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => Kchannel.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), 10, 0.0));
|
||||
Assert.Throws<ArgumentException>(() => Kchannel.Batch(highShort.AsSpan(), low.AsSpan(), close.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), 2));
|
||||
Assert.Throws<ArgumentException>(() => Kchannel.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), smallOut.AsSpan(), upper.AsSpan(), lower.AsSpan(), 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kchannel_SpanBatch_ComputesCorrectly()
|
||||
{
|
||||
double[] high = [105, 110, 115, 112, 118];
|
||||
double[] low = [95, 100, 105, 102, 108];
|
||||
double[] close = [100, 105, 110, 107, 115];
|
||||
double[] middle = new double[5];
|
||||
double[] upper = new double[5];
|
||||
double[] lower = new double[5];
|
||||
|
||||
Kchannel.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), 3);
|
||||
|
||||
// First bar: all equal close
|
||||
Assert.Equal(100.0, middle[0], 1e-10);
|
||||
Assert.Equal(100.0, upper[0], 1e-10);
|
||||
Assert.Equal(100.0, lower[0], 1e-10);
|
||||
|
||||
// Subsequent bars: upper > middle > lower
|
||||
for (int i = 1; i < 5; i++)
|
||||
{
|
||||
Assert.True(upper[i] > middle[i], $"Upper > Middle at {i}");
|
||||
Assert.True(lower[i] < middle[i], $"Lower < Middle at {i}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kchannel_Calculate_ReturnsIndicatorAndResults()
|
||||
{
|
||||
var series = new TBarSeries();
|
||||
series.Add(DateTime.UtcNow, 100, 110, 90, 100, 1000);
|
||||
series.Add(DateTime.UtcNow, 105, 115, 95, 105, 1000);
|
||||
series.Add(DateTime.UtcNow, 102, 112, 92, 102, 1000);
|
||||
|
||||
var ((mid, up, lo), ind) = Kchannel.Calculate(series, 2);
|
||||
|
||||
Assert.True(double.IsFinite(mid.Last.Value));
|
||||
Assert.True(double.IsFinite(up.Last.Value));
|
||||
Assert.True(double.IsFinite(lo.Last.Value));
|
||||
|
||||
// Continue streaming
|
||||
ind.Update(new TBar(DateTime.UtcNow, 108, 118, 98, 108, 1000));
|
||||
Assert.True(double.IsFinite(ind.Last.Value));
|
||||
Assert.True(double.IsFinite(ind.Upper.Value));
|
||||
Assert.True(double.IsFinite(ind.Lower.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kchannel_Event_Publishes()
|
||||
{
|
||||
var src = new TBarSeries();
|
||||
var k = new Kchannel(src, 2);
|
||||
bool fired = false;
|
||||
k.Pub += (object? sender, in TValueEventArgs args) => fired = true;
|
||||
|
||||
src.Add(new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000));
|
||||
Assert.True(fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kchannel_HighVolatility_WiderBands()
|
||||
{
|
||||
var kLow = new Kchannel(20, 2.0);
|
||||
var kHigh = new Kchannel(20, 2.0);
|
||||
|
||||
// Low volatility data
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
kLow.Update(new TBar(DateTime.UtcNow, 100, 101, 99, 100, 1000));
|
||||
}
|
||||
|
||||
// High volatility data
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
kHigh.Update(new TBar(DateTime.UtcNow, 100, 120, 80, 100, 1000));
|
||||
}
|
||||
|
||||
double lowWidth = kLow.Upper.Value - kLow.Lower.Value;
|
||||
double highWidth = kHigh.Upper.Value - kHigh.Lower.Value;
|
||||
|
||||
Assert.True(highWidth > lowWidth, "Higher volatility should produce wider bands");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kchannel_ShorterPeriod_FasterResponse()
|
||||
{
|
||||
var kShort = new Kchannel(5, 2.0);
|
||||
var kLong = new Kchannel(20, 2.0);
|
||||
|
||||
// Initial stable period
|
||||
for (int i = 0; i < 30; i++)
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow, 100, 102, 98, 100, 1000);
|
||||
kShort.Update(bar);
|
||||
kLong.Update(bar);
|
||||
}
|
||||
|
||||
double shortInitial = kShort.Last.Value;
|
||||
double longInitial = kLong.Last.Value;
|
||||
|
||||
// Sudden price jump
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var bar = new TBar(DateTime.UtcNow, 150, 152, 148, 150, 1000);
|
||||
kShort.Update(bar);
|
||||
kLong.Update(bar);
|
||||
}
|
||||
|
||||
double shortMove = kShort.Last.Value - shortInitial;
|
||||
double longMove = kLong.Last.Value - longInitial;
|
||||
|
||||
// Shorter period should respond faster
|
||||
Assert.True(shortMove > longMove, "Shorter period EMA should respond faster to price changes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kchannel_TrueRange_IncludesGaps()
|
||||
{
|
||||
var k = new Kchannel(3, 2.0);
|
||||
|
||||
// Bar 1: normal range
|
||||
k.Update(new TBar(DateTime.UtcNow, 100, 105, 95, 100, 1000));
|
||||
|
||||
// Bar 2: gap up (close was 100, now low is 110)
|
||||
// True range should include the gap: high - prevClose or high - low
|
||||
k.Update(new TBar(DateTime.UtcNow, 115, 120, 110, 115, 1000));
|
||||
|
||||
// ATR should reflect the gap
|
||||
double width = k.Upper.Value - k.Lower.Value;
|
||||
Assert.True(width > 0, "Band width should be positive after gap");
|
||||
|
||||
// Bar 3: another check
|
||||
k.Update(new TBar(DateTime.UtcNow, 118, 122, 114, 118, 1000));
|
||||
Assert.True(double.IsFinite(k.Upper.Value));
|
||||
Assert.True(double.IsFinite(k.Lower.Value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kchannel_WarmupCompensation_ReducesStartupBias()
|
||||
{
|
||||
// Warmup compensation should make early values more accurate
|
||||
var k = new Kchannel(20, 2.0);
|
||||
|
||||
// Create bars with consistent volatility
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
k.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000));
|
||||
}
|
||||
|
||||
// Middle should converge to close (100) as EMA stabilizes
|
||||
Assert.InRange(k.Last.Value, 99.5, 100.5);
|
||||
|
||||
// Band width should stabilize (ATR converges to true range = 20)
|
||||
// Width = Upper - Lower = (EMA + mult*ATR) - (EMA - mult*ATR) = 2 * mult * ATR
|
||||
double expectedWidth = 2.0 * 2.0 * 20.0; // 2 * multiplier * ATR = 80
|
||||
double actualWidth = k.Upper.Value - k.Lower.Value;
|
||||
Assert.InRange(actualWidth, expectedWidth * 0.9, expectedWidth * 1.1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kchannel_LongSeriesStability()
|
||||
{
|
||||
var k = new Kchannel(20, 2.0);
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.001, sigma: 0.02, seed: 123);
|
||||
|
||||
for (int i = 0; i < 10000; i++)
|
||||
{
|
||||
var bar = gbm.Next(isNew: true);
|
||||
k.Update(bar);
|
||||
|
||||
Assert.True(double.IsFinite(k.Last.Value), $"Middle finite at {i}");
|
||||
Assert.True(double.IsFinite(k.Upper.Value), $"Upper finite at {i}");
|
||||
Assert.True(double.IsFinite(k.Lower.Value), $"Lower finite at {i}");
|
||||
|
||||
if (i > 0)
|
||||
{
|
||||
Assert.True(k.Upper.Value > k.Last.Value, $"Upper > Middle at {i}");
|
||||
Assert.True(k.Lower.Value < k.Last.Value, $"Lower < Middle at {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
using Skender.Stock.Indicators;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class KchannelValidationTests : IDisposable
|
||||
{
|
||||
private readonly ValidationTestData _testData;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private bool _disposed;
|
||||
|
||||
public KchannelValidationTests(ITestOutputHelper output)
|
||||
{
|
||||
_output = output;
|
||||
_testData = new ValidationTestData();
|
||||
}
|
||||
|
||||
public void Dispose() => Dispose(true);
|
||||
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
_testData?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ManualCalculation_FirstBars()
|
||||
{
|
||||
var series = new TBarSeries();
|
||||
var t0 = DateTime.UtcNow;
|
||||
|
||||
// Create simple test data
|
||||
// Bar 0: close=100, high=105, low=95 (range=10)
|
||||
series.Add(new TBar(t0, 100, 105, 95, 100, 100));
|
||||
// Bar 1: close=102, high=108, low=98 (range=10, prevClose=100, TR=max(10,8,2)=10)
|
||||
series.Add(new TBar(t0.AddMinutes(1), 102, 108, 98, 102, 100));
|
||||
// Bar 2: close=105, high=112, low=100 (range=12, prevClose=102, TR=max(12,10,2)=12)
|
||||
series.Add(new TBar(t0.AddMinutes(2), 105, 112, 100, 105, 100));
|
||||
|
||||
var ind = new Kchannel(10, 2.0);
|
||||
var (mid, up, lo) = ind.Update(series);
|
||||
|
||||
// First bar: all equal close
|
||||
Assert.Equal(100.0, mid[0].Value, 1e-10);
|
||||
Assert.Equal(100.0, up[0].Value, 1e-10);
|
||||
Assert.Equal(100.0, lo[0].Value, 1e-10);
|
||||
|
||||
// Subsequent bars: upper > middle > lower (bands expand)
|
||||
for (int i = 1; i < mid.Count; i++)
|
||||
{
|
||||
Assert.True(up[i].Value > mid[i].Value, $"Upper > Middle at {i}");
|
||||
Assert.True(lo[i].Value < mid[i].Value, $"Lower < Middle at {i}");
|
||||
}
|
||||
|
||||
// Bands should be symmetric
|
||||
for (int i = 0; i < mid.Count; i++)
|
||||
{
|
||||
double upperDist = up[i].Value - mid[i].Value;
|
||||
double lowerDist = mid[i].Value - lo[i].Value;
|
||||
Assert.Equal(upperDist, lowerDist, 1e-10);
|
||||
}
|
||||
|
||||
_output.WriteLine("Kchannel manual calculation validated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AllModes_Consistency()
|
||||
{
|
||||
int[] periods = { 5, 10, 20, 50 };
|
||||
double[] multipliers = { 1.0, 2.0, 2.5 };
|
||||
|
||||
foreach (int period in periods)
|
||||
{
|
||||
foreach (double multiplier in multipliers)
|
||||
{
|
||||
// Batch (instance)
|
||||
var inst = new Kchannel(period, multiplier);
|
||||
var (bMid, bUp, bLo) = inst.Update(_testData.Bars);
|
||||
|
||||
// Static batch
|
||||
var (sMid, sUp, sLo) = Kchannel.Batch(_testData.Bars, period, multiplier);
|
||||
|
||||
ValidationHelper.VerifySeriesEqual(bMid, sMid);
|
||||
ValidationHelper.VerifySeriesEqual(bUp, sUp);
|
||||
ValidationHelper.VerifySeriesEqual(bLo, sLo);
|
||||
|
||||
// Streaming
|
||||
var streaming = new Kchannel(period, multiplier);
|
||||
var sMidStream = new TSeries();
|
||||
var sUpStream = new TSeries();
|
||||
var sLoStream = new TSeries();
|
||||
foreach (var bar in _testData.Bars)
|
||||
{
|
||||
streaming.Update(bar);
|
||||
sMidStream.Add(streaming.Last);
|
||||
sUpStream.Add(streaming.Upper);
|
||||
sLoStream.Add(streaming.Lower);
|
||||
}
|
||||
|
||||
ValidationHelper.VerifySeriesEqual(sMid, sMidStream);
|
||||
ValidationHelper.VerifySeriesEqual(sUp, sUpStream);
|
||||
ValidationHelper.VerifySeriesEqual(sLo, sLoStream);
|
||||
|
||||
// Span
|
||||
double[] high = _testData.HighPrices.ToArray();
|
||||
double[] low = _testData.LowPrices.ToArray();
|
||||
double[] close = _testData.ClosePrices.ToArray();
|
||||
double[] spanMid = new double[high.Length];
|
||||
double[] spanUp = new double[high.Length];
|
||||
double[] spanLo = new double[high.Length];
|
||||
Kchannel.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(),
|
||||
spanMid.AsSpan(), spanUp.AsSpan(), spanLo.AsSpan(), period, multiplier);
|
||||
|
||||
for (int i = 0; i < high.Length; i++)
|
||||
{
|
||||
Assert.Equal(sMid[i].Value, spanMid[i], 9);
|
||||
Assert.Equal(sUp[i].Value, spanUp[i], 9);
|
||||
Assert.Equal(sLo[i].Value, spanLo[i], 9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_output.WriteLine("Kchannel mode consistency validated (batch/stream/span)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_EventingMode_MatchesBatch()
|
||||
{
|
||||
const int period = 20;
|
||||
const double multiplier = 2.0;
|
||||
|
||||
var pub = new TBarSeries();
|
||||
var evtInd = new Kchannel(pub, period, multiplier);
|
||||
var evtMid = new TSeries();
|
||||
var evtUp = new TSeries();
|
||||
var evtLo = new TSeries();
|
||||
|
||||
foreach (var bar in _testData.Bars)
|
||||
{
|
||||
pub.Add(bar);
|
||||
evtMid.Add(evtInd.Last);
|
||||
evtUp.Add(evtInd.Upper);
|
||||
evtLo.Add(evtInd.Lower);
|
||||
}
|
||||
|
||||
var (bMid, bUp, bLo) = Kchannel.Batch(_testData.Bars, period, multiplier);
|
||||
|
||||
ValidationHelper.VerifySeriesEqual(bMid, evtMid);
|
||||
ValidationHelper.VerifySeriesEqual(bUp, evtUp);
|
||||
ValidationHelper.VerifySeriesEqual(bLo, evtLo);
|
||||
|
||||
_output.WriteLine("Kchannel eventing mode validated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Calculate_ReturnsHotIndicator()
|
||||
{
|
||||
const int period = 15;
|
||||
const double multiplier = 2.5;
|
||||
|
||||
var ((mid, up, lo), ind) = Kchannel.Calculate(_testData.Bars, period, multiplier);
|
||||
|
||||
Assert.True(ind.IsHot);
|
||||
Assert.Equal(period * 2, ind.WarmupPeriod);
|
||||
Assert.Equal(mid.Last.Value, ind.Last.Value, 1e-10);
|
||||
Assert.Equal(up.Last.Value, ind.Upper.Value, 1e-10);
|
||||
Assert.Equal(lo.Last.Value, ind.Lower.Value, 1e-10);
|
||||
|
||||
// Continue streaming
|
||||
var next = new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000);
|
||||
ind.Update(next);
|
||||
Assert.True(ind.IsHot);
|
||||
|
||||
_output.WriteLine("Kchannel Calculate validated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Prime_MatchesBatch()
|
||||
{
|
||||
const int period = 25;
|
||||
const double multiplier = 1.5;
|
||||
|
||||
var (bMid, bUp, bLo) = Kchannel.Batch(_testData.Bars, period, multiplier);
|
||||
|
||||
var primed = new Kchannel(period, multiplier);
|
||||
var subset = new TBarSeries();
|
||||
for (int i = 0; i < 200; i++)
|
||||
{
|
||||
subset.Add(_testData.Bars[i]);
|
||||
}
|
||||
|
||||
primed.Prime(subset);
|
||||
|
||||
for (int i = 200; i < _testData.Bars.Count; i++)
|
||||
{
|
||||
primed.Update(_testData.Bars[i]);
|
||||
}
|
||||
|
||||
Assert.Equal(bMid.Last.Value, primed.Last.Value, 1e-9);
|
||||
Assert.Equal(bUp.Last.Value, primed.Upper.Value, 1e-9);
|
||||
Assert.Equal(bLo.Last.Value, primed.Lower.Value, 1e-9);
|
||||
|
||||
_output.WriteLine("Kchannel Prime validated against batch");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_LargeDataset_FiniteOutputs()
|
||||
{
|
||||
var (mid, up, lo) = Kchannel.Batch(_testData.Bars, 50, 2.0);
|
||||
|
||||
ValidationHelper.VerifyAllFinite(mid, startIndex: 0);
|
||||
ValidationHelper.VerifyAllFinite(up, startIndex: 0);
|
||||
ValidationHelper.VerifyAllFinite(lo, startIndex: 0);
|
||||
|
||||
// After first bar, upper > lower
|
||||
for (int i = 1; i < mid.Count; i++)
|
||||
{
|
||||
Assert.True(up[i].Value > lo[i].Value, $"Upper > Lower at {i}");
|
||||
}
|
||||
|
||||
_output.WriteLine("Kchannel large dataset validated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_BandSymmetry_AllBars()
|
||||
{
|
||||
var ind = new Kchannel(20, 2.0);
|
||||
var (mid, up, lo) = ind.Update(_testData.Bars);
|
||||
|
||||
for (int i = 0; i < mid.Count; i++)
|
||||
{
|
||||
double upperWidth = up[i].Value - mid[i].Value;
|
||||
double lowerWidth = mid[i].Value - lo[i].Value;
|
||||
Assert.Equal(upperWidth, lowerWidth, 1e-10);
|
||||
}
|
||||
|
||||
_output.WriteLine("Kchannel band symmetry validated for all bars");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MultiplierScaling()
|
||||
{
|
||||
double[] multipliers = { 1.0, 2.0, 3.0, 4.0 };
|
||||
double[] widths = new double[multipliers.Length];
|
||||
|
||||
for (int i = 0; i < multipliers.Length; i++)
|
||||
{
|
||||
var ind = new Kchannel(20, multipliers[i]);
|
||||
foreach (var bar in _testData.Bars)
|
||||
{
|
||||
ind.Update(bar);
|
||||
}
|
||||
widths[i] = ind.Upper.Value - ind.Lower.Value;
|
||||
}
|
||||
|
||||
// Widths should scale linearly with multiplier
|
||||
double baseWidth = widths[0];
|
||||
for (int i = 1; i < multipliers.Length; i++)
|
||||
{
|
||||
double expected = baseWidth * multipliers[i];
|
||||
Assert.Equal(expected, widths[i], 1e-9);
|
||||
}
|
||||
|
||||
_output.WriteLine("Kchannel multiplier scaling validated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_PeriodEffect_Smoothing()
|
||||
{
|
||||
int[] periods = { 5, 10, 20, 50 };
|
||||
double[] middles = new double[periods.Length];
|
||||
|
||||
for (int i = 0; i < periods.Length; i++)
|
||||
{
|
||||
var ind = new Kchannel(periods[i], 2.0);
|
||||
foreach (var bar in _testData.Bars)
|
||||
{
|
||||
ind.Update(bar);
|
||||
}
|
||||
middles[i] = ind.Last.Value;
|
||||
}
|
||||
|
||||
// All should produce finite values
|
||||
foreach (var m in middles)
|
||||
{
|
||||
Assert.True(double.IsFinite(m));
|
||||
}
|
||||
|
||||
_output.WriteLine("Kchannel period effect validated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ATRComponent_TrueRange()
|
||||
{
|
||||
// Create data with gaps to verify True Range includes gaps
|
||||
var series = new TBarSeries();
|
||||
var t0 = DateTime.UtcNow;
|
||||
|
||||
// Bar 0: normal
|
||||
series.Add(new TBar(t0, 100, 105, 95, 100, 100));
|
||||
// Bar 1: gap up (prev close=100, new low=110, gap=10)
|
||||
series.Add(new TBar(t0.AddMinutes(1), 115, 120, 110, 115, 100));
|
||||
// Bar 2: gap down (prev close=115, new high=100)
|
||||
series.Add(new TBar(t0.AddMinutes(2), 95, 100, 90, 95, 100));
|
||||
|
||||
var ind = new Kchannel(3, 2.0);
|
||||
var (mid, up, lo) = ind.Update(series);
|
||||
|
||||
// Bands should expand due to gaps
|
||||
for (int i = 1; i < mid.Count; i++)
|
||||
{
|
||||
double width = up[i].Value - lo[i].Value;
|
||||
Assert.True(width > 0, $"Band width > 0 at bar {i}");
|
||||
}
|
||||
|
||||
_output.WriteLine("Kchannel ATR true range validated with gaps");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_WarmupCompensation_EarlyConvergence()
|
||||
{
|
||||
// Constant price data - EMA should converge quickly due to warmup compensation
|
||||
var series = new TBarSeries();
|
||||
var t0 = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
series.Add(new TBar(t0.AddMinutes(i), 100, 105, 95, 100, 100));
|
||||
}
|
||||
|
||||
var ind = new Kchannel(20, 2.0);
|
||||
var (mid, _, _) = ind.Update(series);
|
||||
|
||||
// After warmup, middle should be very close to constant price
|
||||
for (int i = 40; i < 100; i++)
|
||||
{
|
||||
Assert.InRange(mid[i].Value, 99.9, 100.1);
|
||||
}
|
||||
|
||||
_output.WriteLine("Kchannel warmup compensation validated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_StateRestoration_Iterative()
|
||||
{
|
||||
var ind = new Kchannel(15, 2.5);
|
||||
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 42);
|
||||
|
||||
// Build up state
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
ind.Update(gbm.Next(isNew: true), isNew: true);
|
||||
}
|
||||
|
||||
// Multiple corrections
|
||||
var remembered = gbm.Next(isNew: true);
|
||||
ind.Update(remembered, isNew: true);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
var corrected = gbm.Next(isNew: false);
|
||||
ind.Update(corrected, isNew: false);
|
||||
}
|
||||
|
||||
// Restore
|
||||
ind.Update(remembered, isNew: false);
|
||||
|
||||
// State should be back to remembered point (after remembered bar)
|
||||
Assert.True(double.IsFinite(ind.Last.Value));
|
||||
Assert.True(double.IsFinite(ind.Upper.Value));
|
||||
Assert.True(double.IsFinite(ind.Lower.Value));
|
||||
|
||||
_output.WriteLine("Kchannel state restoration validated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_SkenderComparison_BandStructure()
|
||||
{
|
||||
// Skender uses ATR-based bands similar to our implementation
|
||||
// Validate structural correctness: upper > middle > lower, symmetric bands
|
||||
|
||||
var skenderPeriod = 20;
|
||||
var skenderMultiplier = 2.0;
|
||||
|
||||
// Get Skender results (they use EMA middle + ATR bands)
|
||||
var skenderResults = _testData.SkenderQuotes
|
||||
.GetKeltner(skenderPeriod, skenderMultiplier)
|
||||
.ToList();
|
||||
|
||||
// Get our results
|
||||
var (ourMid, _, _) = Kchannel.Batch(_testData.Bars, skenderPeriod, skenderMultiplier);
|
||||
|
||||
// Both should have upper > middle > lower structure
|
||||
int warmup = skenderPeriod * 2;
|
||||
for (int i = warmup; i < ourMid.Count && i < skenderResults.Count; i++)
|
||||
{
|
||||
var sk = skenderResults[i];
|
||||
if (sk.UpperBand.HasValue && sk.LowerBand.HasValue && sk.Centerline.HasValue)
|
||||
{
|
||||
// Structural check
|
||||
Assert.True(sk.UpperBand.Value > sk.Centerline.Value, $"Skender Upper > Middle at {i}");
|
||||
Assert.True(sk.LowerBand.Value < sk.Centerline.Value, $"Skender Lower < Middle at {i}");
|
||||
|
||||
// Both use symmetric ATR-based bands
|
||||
double skWidth = sk.UpperBand.Value - sk.LowerBand.Value;
|
||||
|
||||
Assert.True(skWidth > 0, $"Skender width > 0 at {i}");
|
||||
}
|
||||
}
|
||||
|
||||
_output.WriteLine($"Kchannel vs Skender structure validated (period={skenderPeriod}, mult={skenderMultiplier})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_SkenderComparison_ApproximateMatch()
|
||||
{
|
||||
// Note: Skender may use slightly different ATR/EMA warmup, so we check approximate match
|
||||
// Our implementation uses sum/weight warmup compensation; Skender may not
|
||||
|
||||
var skenderPeriod = 20;
|
||||
var skenderMultiplier = 2.0;
|
||||
|
||||
var skenderResults = _testData.SkenderQuotes
|
||||
.GetKeltner(skenderPeriod, skenderMultiplier)
|
||||
.ToList();
|
||||
|
||||
var (ourMid, _, _) = Kchannel.Batch(_testData.Bars, skenderPeriod, skenderMultiplier);
|
||||
|
||||
// Compare after significant warmup (values should converge)
|
||||
int compareStart = skenderPeriod * 5; // Well past warmup
|
||||
int closeCount = 0;
|
||||
|
||||
for (int i = compareStart; i < Math.Min(ourMid.Count, skenderResults.Count); i++)
|
||||
{
|
||||
var sk = skenderResults[i];
|
||||
if (sk.Centerline.HasValue)
|
||||
{
|
||||
double midDiff = Math.Abs(ourMid[i].Value - sk.Centerline.Value);
|
||||
double midPct = midDiff / Math.Max(1, Math.Abs(sk.Centerline.Value));
|
||||
|
||||
// After warmup, values should be within 5% (warmup methods may differ)
|
||||
if (midPct < 0.05)
|
||||
closeCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Most values should be close
|
||||
int total = Math.Min(ourMid.Count, skenderResults.Count) - compareStart;
|
||||
double closeRatio = (double)closeCount / total;
|
||||
Assert.True(closeRatio > 0.9, $"Close ratio {closeRatio:P0} should be > 90%");
|
||||
|
||||
_output.WriteLine($"Kchannel vs Skender approximate match: {closeRatio:P0} within 5%");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_BandWidthConsistency()
|
||||
{
|
||||
// Verify that band width is consistent across different calculation modes
|
||||
int[] periods = { 10, 20, 30 };
|
||||
|
||||
foreach (int period in periods)
|
||||
{
|
||||
var (mid, up, lo) = Kchannel.Batch(_testData.Bars, period, 2.0);
|
||||
|
||||
// Band width should be exactly 2x ATR (multiplier * ATR)
|
||||
for (int i = 1; i < mid.Count; i++)
|
||||
{
|
||||
double width = up[i].Value - lo[i].Value;
|
||||
double upperDist = up[i].Value - mid[i].Value;
|
||||
double lowerDist = mid[i].Value - lo[i].Value;
|
||||
|
||||
// Width = 2 * ATR * multiplier, so upperDist = lowerDist = ATR * multiplier
|
||||
Assert.Equal(upperDist, lowerDist, 1e-10);
|
||||
Assert.Equal(width, upperDist + lowerDist, 1e-10);
|
||||
}
|
||||
}
|
||||
|
||||
_output.WriteLine("Kchannel band width consistency validated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ATRCalculation_Correctness()
|
||||
{
|
||||
// Verify ATR calculation using known values
|
||||
var series = new TBarSeries();
|
||||
var t0 = DateTime.UtcNow;
|
||||
|
||||
// Create bars with known true range values
|
||||
// Bar 0: TR = high - low = 10 (no previous close)
|
||||
series.Add(new TBar(t0, 100, 105, 95, 100, 100));
|
||||
// Bar 1: TR = max(110-90, |110-100|, |90-100|) = max(20, 10, 10) = 20
|
||||
series.Add(new TBar(t0.AddMinutes(1), 100, 110, 90, 100, 100));
|
||||
// Bar 2: TR = max(105-95, |105-100|, |95-100|) = max(10, 5, 5) = 10
|
||||
series.Add(new TBar(t0.AddMinutes(2), 100, 105, 95, 100, 100));
|
||||
|
||||
var ind = new Kchannel(3, 1.0); // multiplier=1 so width = 2*ATR
|
||||
var (mid, up, lo) = ind.Update(series);
|
||||
|
||||
// All outputs should be finite
|
||||
for (int i = 0; i < mid.Count; i++)
|
||||
{
|
||||
Assert.True(double.IsFinite(mid[i].Value));
|
||||
Assert.True(double.IsFinite(up[i].Value));
|
||||
Assert.True(double.IsFinite(lo[i].Value));
|
||||
}
|
||||
|
||||
// Band width should be positive after first bar
|
||||
for (int i = 1; i < mid.Count; i++)
|
||||
{
|
||||
double width = up[i].Value - lo[i].Value;
|
||||
Assert.True(width > 0, $"Band width > 0 at bar {i}");
|
||||
}
|
||||
|
||||
_output.WriteLine("Kchannel ATR calculation validated");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// KCHANNEL: Keltner Channel
|
||||
/// A volatility-based envelope using EMA as the middle line and ATR for band width.
|
||||
/// Middle = EMA(source, period) with warmup compensation
|
||||
/// Upper = Middle + (multiplier × ATR)
|
||||
/// Lower = Middle - (multiplier × ATR)
|
||||
/// ATR uses RMA (Wilder's smoothing) with warmup compensation.
|
||||
/// </summary>
|
||||
[SkipLocalsInit]
|
||||
public sealed class Kchannel : ITValuePublisher
|
||||
{
|
||||
private readonly int _period;
|
||||
private readonly double _multiplier;
|
||||
private readonly double _emaAlpha;
|
||||
private readonly double _atrAlpha;
|
||||
|
||||
[StructLayout(LayoutKind.Auto)]
|
||||
private record struct State(
|
||||
double EmaSum,
|
||||
double EmaWeight,
|
||||
double RawRma,
|
||||
double E,
|
||||
double PrevClose,
|
||||
double LastValidClose,
|
||||
double LastValidHigh,
|
||||
double LastValidLow,
|
||||
int Bars,
|
||||
bool IsHot);
|
||||
|
||||
private State _state;
|
||||
private State _p_state;
|
||||
|
||||
private readonly TBarPublishedHandler _barHandler;
|
||||
|
||||
private const double Epsilon = 1e-10;
|
||||
|
||||
public string Name { get; }
|
||||
public int WarmupPeriod { get; }
|
||||
public TValue Last { get; private set; }
|
||||
public TValue Upper { get; private set; }
|
||||
public TValue Lower { get; private set; }
|
||||
public bool IsHot => _state.IsHot;
|
||||
|
||||
public event TValuePublishedHandler? Pub;
|
||||
|
||||
public Kchannel(int period = 20, double multiplier = 2.0)
|
||||
{
|
||||
if (period < 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(period), "Period must be >= 1.");
|
||||
if (multiplier <= 0.0)
|
||||
throw new ArgumentOutOfRangeException(nameof(multiplier), "Multiplier must be > 0.");
|
||||
|
||||
_period = period;
|
||||
_multiplier = multiplier;
|
||||
_emaAlpha = 2.0 / (period + 1);
|
||||
_atrAlpha = 1.0 / period;
|
||||
|
||||
WarmupPeriod = period * 2;
|
||||
|
||||
Name = $"Kchannel({period},{multiplier})";
|
||||
_barHandler = HandleBar;
|
||||
|
||||
Reset();
|
||||
}
|
||||
|
||||
public Kchannel(TBarSeries source, int period = 20, double multiplier = 2.0) : this(period, multiplier)
|
||||
{
|
||||
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 void Reset()
|
||||
{
|
||||
_state = new State(0, 0, 0, 1.0, double.NaN, double.NaN, double.NaN, double.NaN, 0, false);
|
||||
_p_state = _state;
|
||||
Last = default;
|
||||
Upper = default;
|
||||
Lower = default;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private (double close, double high, double low) GetValid(double close, double high, double low)
|
||||
{
|
||||
if (double.IsFinite(close))
|
||||
_state = _state with { LastValidClose = close };
|
||||
else
|
||||
close = _state.LastValidClose;
|
||||
|
||||
if (double.IsFinite(high))
|
||||
_state = _state with { LastValidHigh = high };
|
||||
else
|
||||
high = _state.LastValidHigh;
|
||||
|
||||
if (double.IsFinite(low))
|
||||
_state = _state with { LastValidLow = low };
|
||||
else
|
||||
low = _state.LastValidLow;
|
||||
|
||||
return (close, high, low);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public TValue Update(TBar input, bool isNew = true)
|
||||
{
|
||||
if (isNew)
|
||||
_p_state = _state;
|
||||
else
|
||||
_state = _p_state;
|
||||
|
||||
var (close, high, low) = GetValid(input.Close, input.High, input.Low);
|
||||
|
||||
// Handle first bar
|
||||
if (_state.Bars == 0)
|
||||
{
|
||||
_state = _state with
|
||||
{
|
||||
EmaSum = close,
|
||||
EmaWeight = 1.0,
|
||||
RawRma = 0.0,
|
||||
E = 1.0,
|
||||
PrevClose = close,
|
||||
Bars = 1
|
||||
};
|
||||
|
||||
double ema = close;
|
||||
Last = new TValue(input.Time, ema);
|
||||
Upper = new TValue(input.Time, ema);
|
||||
Lower = new TValue(input.Time, ema);
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
if (isNew)
|
||||
_state = _state with { Bars = _state.Bars + 1 };
|
||||
|
||||
// EMA with warmup compensation (sum/weight approach)
|
||||
double newSum = Math.FusedMultiplyAdd(_state.EmaSum, 1.0 - _emaAlpha, close * _emaAlpha);
|
||||
double newWeight = Math.FusedMultiplyAdd(_state.EmaWeight, 1.0 - _emaAlpha, _emaAlpha);
|
||||
double emaValue = newSum / newWeight;
|
||||
|
||||
// True Range
|
||||
double prevClose = _state.PrevClose;
|
||||
double tr1 = high - low;
|
||||
double tr2 = Math.Abs(high - prevClose);
|
||||
double tr3 = Math.Abs(low - prevClose);
|
||||
double trueRange = Math.Max(tr1, Math.Max(tr2, tr3));
|
||||
|
||||
// ATR using RMA with warmup compensation
|
||||
double newRawRma = (_state.RawRma * (_period - 1) + trueRange) / _period;
|
||||
double newE = (1.0 - _atrAlpha) * _state.E;
|
||||
double atrValue = newE > Epsilon ? newRawRma / (1.0 - newE) : newRawRma;
|
||||
|
||||
// Update state
|
||||
_state = _state with
|
||||
{
|
||||
EmaSum = newSum,
|
||||
EmaWeight = newWeight,
|
||||
RawRma = newRawRma,
|
||||
E = newE,
|
||||
PrevClose = close
|
||||
};
|
||||
|
||||
// Calculate bands
|
||||
double width = _multiplier * atrValue;
|
||||
double upper = emaValue + width;
|
||||
double lower = emaValue - width;
|
||||
|
||||
if (!_state.IsHot && _state.Bars >= WarmupPeriod)
|
||||
_state = _state with { IsHot = true };
|
||||
|
||||
Last = new TValue(input.Time, emaValue);
|
||||
Upper = new TValue(input.Time, upper);
|
||||
Lower = new TValue(input.Time, lower);
|
||||
|
||||
PubEvent(Last, isNew);
|
||||
return Last;
|
||||
}
|
||||
|
||||
public (TSeries Middle, TSeries Upper, TSeries Lower) Update(TBarSeries source)
|
||||
{
|
||||
if (source.Count == 0)
|
||||
return (new TSeries([], []), new TSeries([], []), new TSeries([], []));
|
||||
|
||||
int len = source.Count;
|
||||
var tMiddle = new List<long>(len);
|
||||
var vMiddle = new List<double>(len);
|
||||
var tUpper = new List<long>(len);
|
||||
var vUpper = new List<double>(len);
|
||||
var tLower = new List<long>(len);
|
||||
var vLower = new List<double>(len);
|
||||
|
||||
CollectionsMarshal.SetCount(tMiddle, len);
|
||||
CollectionsMarshal.SetCount(vMiddle, len);
|
||||
CollectionsMarshal.SetCount(tUpper, len);
|
||||
CollectionsMarshal.SetCount(vUpper, len);
|
||||
CollectionsMarshal.SetCount(tLower, len);
|
||||
CollectionsMarshal.SetCount(vLower, len);
|
||||
|
||||
var tSpan = CollectionsMarshal.AsSpan(tMiddle);
|
||||
var vMiddleSpan = CollectionsMarshal.AsSpan(vMiddle);
|
||||
var vUpperSpan = CollectionsMarshal.AsSpan(vUpper);
|
||||
var vLowerSpan = CollectionsMarshal.AsSpan(vLower);
|
||||
|
||||
Batch(source.HighValues, source.LowValues, source.CloseValues,
|
||||
vMiddleSpan, vUpperSpan, vLowerSpan, _period, _multiplier);
|
||||
|
||||
source.Times.CopyTo(tSpan);
|
||||
tSpan.CopyTo(CollectionsMarshal.AsSpan(tUpper));
|
||||
tSpan.CopyTo(CollectionsMarshal.AsSpan(tLower));
|
||||
|
||||
// Prime internal state for continued streaming
|
||||
Prime(source);
|
||||
|
||||
var lastTime = new DateTime(source.Times[^1], DateTimeKind.Utc);
|
||||
Last = new TValue(lastTime, vMiddleSpan[^1]);
|
||||
Upper = new TValue(lastTime, vUpperSpan[^1]);
|
||||
Lower = new TValue(lastTime, vLowerSpan[^1]);
|
||||
|
||||
return (new TSeries(tMiddle, vMiddle), new TSeries(tUpper, vUpper), new TSeries(tLower, vLower));
|
||||
}
|
||||
|
||||
public void Prime(TBarSeries source)
|
||||
{
|
||||
Reset();
|
||||
|
||||
if (source.Count == 0)
|
||||
return;
|
||||
|
||||
for (int i = 0; i < source.Count; i++)
|
||||
{
|
||||
Update(source[i], isNew: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Batch calculation using spans (zero allocation).
|
||||
/// </summary>
|
||||
public static void Batch(
|
||||
ReadOnlySpan<double> high,
|
||||
ReadOnlySpan<double> low,
|
||||
ReadOnlySpan<double> close,
|
||||
Span<double> middle,
|
||||
Span<double> upper,
|
||||
Span<double> lower,
|
||||
int period,
|
||||
double multiplier = 2.0)
|
||||
{
|
||||
if (period < 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(period), "Period must be >= 1.");
|
||||
if (multiplier <= 0.0)
|
||||
throw new ArgumentOutOfRangeException(nameof(multiplier), "Multiplier must be > 0.");
|
||||
if (high.Length != low.Length || high.Length != close.Length)
|
||||
throw new ArgumentException("High, Low, and Close spans must have the same length", nameof(high));
|
||||
if (middle.Length < high.Length || upper.Length < high.Length || lower.Length < high.Length)
|
||||
throw new ArgumentException("Output spans must be at least as long as inputs", nameof(middle));
|
||||
|
||||
int len = high.Length;
|
||||
if (len == 0) return;
|
||||
|
||||
double emaAlpha = 2.0 / (period + 1);
|
||||
double atrAlpha = 1.0 / period;
|
||||
|
||||
double emaSum = close[0];
|
||||
double emaWeight = 1.0;
|
||||
double rawRma = 0.0;
|
||||
double e = 1.0;
|
||||
double prevClose = close[0];
|
||||
|
||||
// First bar
|
||||
middle[0] = close[0];
|
||||
upper[0] = close[0];
|
||||
lower[0] = close[0];
|
||||
|
||||
for (int i = 1; i < len; i++)
|
||||
{
|
||||
double c = close[i];
|
||||
double h = high[i];
|
||||
double l = low[i];
|
||||
|
||||
// EMA with warmup
|
||||
emaSum = Math.FusedMultiplyAdd(emaSum, 1.0 - emaAlpha, c * emaAlpha);
|
||||
emaWeight = Math.FusedMultiplyAdd(emaWeight, 1.0 - emaAlpha, emaAlpha);
|
||||
double ema = emaSum / emaWeight;
|
||||
|
||||
// True Range
|
||||
double tr1 = h - l;
|
||||
double tr2 = Math.Abs(h - prevClose);
|
||||
double tr3 = Math.Abs(l - prevClose);
|
||||
double tr = Math.Max(tr1, Math.Max(tr2, tr3));
|
||||
|
||||
// ATR (RMA with warmup)
|
||||
rawRma = (rawRma * (period - 1) + tr) / period;
|
||||
e = (1.0 - atrAlpha) * e;
|
||||
double atr = e > Epsilon ? rawRma / (1.0 - e) : rawRma;
|
||||
|
||||
prevClose = c;
|
||||
|
||||
double width = multiplier * atr;
|
||||
middle[i] = ema;
|
||||
upper[i] = ema + width;
|
||||
lower[i] = ema - width;
|
||||
}
|
||||
}
|
||||
|
||||
public static (TSeries Middle, TSeries Upper, TSeries Lower) Batch(TBarSeries source, int period = 20, double multiplier = 2.0)
|
||||
{
|
||||
int len = source.Count;
|
||||
var tMiddle = new List<long>(len);
|
||||
var vMiddle = new List<double>(len);
|
||||
var tUpper = new List<long>(len);
|
||||
var vUpper = new List<double>(len);
|
||||
var tLower = new List<long>(len);
|
||||
var vLower = new List<double>(len);
|
||||
|
||||
CollectionsMarshal.SetCount(tMiddle, len);
|
||||
CollectionsMarshal.SetCount(vMiddle, len);
|
||||
CollectionsMarshal.SetCount(tUpper, len);
|
||||
CollectionsMarshal.SetCount(vUpper, len);
|
||||
CollectionsMarshal.SetCount(tLower, len);
|
||||
CollectionsMarshal.SetCount(vLower, len);
|
||||
|
||||
Batch(source.HighValues, source.LowValues, source.CloseValues,
|
||||
CollectionsMarshal.AsSpan(vMiddle),
|
||||
CollectionsMarshal.AsSpan(vUpper),
|
||||
CollectionsMarshal.AsSpan(vLower),
|
||||
period, multiplier);
|
||||
|
||||
source.Times.CopyTo(CollectionsMarshal.AsSpan(tMiddle));
|
||||
CollectionsMarshal.AsSpan(tMiddle).CopyTo(CollectionsMarshal.AsSpan(tUpper));
|
||||
CollectionsMarshal.AsSpan(tMiddle).CopyTo(CollectionsMarshal.AsSpan(tLower));
|
||||
|
||||
return (new TSeries(tMiddle, vMiddle), new TSeries(tUpper, vUpper), new TSeries(tLower, vLower));
|
||||
}
|
||||
|
||||
public static ((TSeries Middle, TSeries Upper, TSeries Lower) Results, Kchannel Indicator) Calculate(TBarSeries source, int period = 20, double multiplier = 2.0)
|
||||
{
|
||||
var indicator = new Kchannel(source, period, multiplier);
|
||||
var results = indicator.Update(source);
|
||||
return (results, indicator);
|
||||
}
|
||||
}
|
||||
@@ -1,120 +1,225 @@
|
||||
# KCHANNEL: Keltner Channels
|
||||
# KCHANNEL: Keltner Channel
|
||||
|
||||
## Overview and Purpose
|
||||
> "Chester Keltner understood that volatility defines opportunity—his channel shows where price *should* travel, not just where it has been."
|
||||
|
||||
Keltner Channels are volatility-based envelopes that create an adaptive price corridor around an exponential moving average. Unlike fixed percentage bands, Keltner Channels use the Average True Range (ATR) to determine their width, allowing them to dynamically adjust to changing market conditions. This approach creates bands that expand during volatile periods and contract during calm markets, providing traders with a visual framework for identifying potential support and resistance levels, overbought and oversold conditions, and trend strength.
|
||||
Keltner Channel wraps an Exponential Moving Average (EMA) with bands based on Average True Range (ATR). The middle band tracks trend direction via EMA smoothing; the upper and lower bands expand and contract with market volatility. Unlike Bollinger Bands that use standard deviation (sensitive to outliers), Keltner uses ATR—a volatility measure designed specifically for price movement that includes gaps.
|
||||
|
||||
The implementation provided uses efficient circular buffer techniques for EMA calculation and optimized ATR smoothing, ensuring consistent performance and numerical stability. By combining price trend (via EMA) with volatility measurement (via ATR), Keltner Channels offer a more comprehensive view of market dynamics than either component alone, making them valuable for both trend identification and mean reversion strategies.
|
||||
## Historical Context
|
||||
|
||||
## Core Concepts
|
||||
Chester W. Keltner introduced the original Keltner Channel in his 1960 book "How to Make Money in Commodities." His version used a 10-period Simple Moving Average of the "typical price" (HLC/3) with bands at the 10-period average range.
|
||||
|
||||
* **Adaptive volatility bands:** Width automatically expands and contracts based on market volatility as measured by ATR
|
||||
* **Trend-following baseline:** Uses an EMA as the middle line, providing a moving reference point that follows the underlying trend
|
||||
* **Volume-independent measurement:** Unlike some other volatility indicators, does not require volume data, making it suitable for all markets
|
||||
* **Dynamic support/resistance zones:** Creates natural price zones that adapt to changing market conditions rather than fixed levels
|
||||
Linda Bradford Raschke modernized the formula in the 1980s, replacing SMA with EMA for smoother trend following and swapping average range for Average True Range to properly account for gaps. Most modern implementations—including this one—follow Raschke's formulation with a 20-period EMA and 2× ATR width.
|
||||
|
||||
Keltner Channels differ from other volatility bands like Bollinger Bands by using ATR rather than standard deviation to calculate width. This approach is often considered more responsive to directional volatility and less susceptible to isolated price spikes that might temporarily inflate standard deviation calculations, resulting in bands that more accurately reflect true market volatility.
|
||||
The PineScript reference algorithm adds warmup compensation: instead of the traditional EMA formula that converges slowly from the first value, it tracks cumulative weighted sums to produce accurate values even during warmup. This implementation replicates that approach for both EMA and ATR (via RMA/Wilder smoothing).
|
||||
|
||||
## Common Settings and Parameters
|
||||
## Architecture & Physics
|
||||
|
||||
| Parameter | Default | Function | When to Adjust |
|
||||
| ------ | ------ | ------ | ------ |
|
||||
| Length | 20 | Lookback period for both EMA and ATR calculations | Shorter for more sensitivity to recent volatility; longer for more stable bands |
|
||||
| ATR Multiplier | 2.0 | Determines band width as multiple of ATR | Higher values for wider bands that trigger fewer signals; lower values for tighter bands with more frequent signals |
|
||||
| Source | Close | Price data for middle line calculation | Rarely needs adjustment unless analyzing specific price aspects |
|
||||
Keltner Channel consists of three interdependent components: the EMA middle band, the ATR volatility measure, and the upper/lower bands.
|
||||
|
||||
**Pro Tip:** For effective trend identification with reduced noise, try using length = 50 with a multiplier of 2.5. This configuration creates bands wide enough to filter minor retracements while still capturing significant trend changes. For shorter-term trading, length = 10 with multiplier = 1.5 can identify short-term overbought/oversold conditions.
|
||||
### 1. Exponential Moving Average (Middle Band)
|
||||
|
||||
## Calculation and Mathematical Foundation
|
||||
The middle band uses EMA with warmup compensation:
|
||||
|
||||
**Simplified explanation:**
|
||||
Keltner Channels calculate a middle line using an exponential moving average of the price. They then create upper and lower bands by adding or subtracting the average true range (multiplied by a factor) from this middle line.
|
||||
$$
|
||||
\alpha = \frac{2}{\text{period} + 1}
|
||||
$$
|
||||
|
||||
**Technical formula:**
|
||||
$$
|
||||
S_t = S_{t-1} \cdot (1 - \alpha) + P_t \cdot \alpha
|
||||
$$
|
||||
|
||||
Middle Band = EMA(Source, Length)
|
||||
Upper Band = Middle Band + (ATR(Length) × Multiplier)
|
||||
Lower Band = Middle Band - (ATR(Length) × Multiplier)
|
||||
$$
|
||||
W_t = W_{t-1} \cdot (1 - \alpha) + \alpha
|
||||
$$
|
||||
|
||||
Where:
|
||||
* EMA = Exponential Moving Average
|
||||
* ATR = Average True Range using Wilder's smoothing
|
||||
* Length = Lookback period for calculations
|
||||
* Multiplier = Factor for band width
|
||||
$$
|
||||
\text{EMA}_t = \frac{S_t}{W_t}
|
||||
$$
|
||||
|
||||
> 🔍 **Technical Note:** The implementation uses an optimized approach for both EMA and ATR calculations, maintaining circular buffers to prevent memory growth while ensuring numerical stability. The EMA calculation includes proper initialization and bias correction to prevent the common "warm-up effect" seen in many EMA implementations.
|
||||
where $S$ is the cumulative weighted sum, $W$ is the cumulative weight, and $P$ is the close price. The division by $W_t$ compensates for the geometric decay during warmup, producing accurate values from the first bar rather than requiring period bars to converge.
|
||||
|
||||
### 2. True Range
|
||||
|
||||
True Range captures the full price movement including gaps:
|
||||
|
||||
$$
|
||||
\text{TR}_t = \max\begin{cases}
|
||||
H_t - L_t \\
|
||||
|H_t - C_{t-1}| \\
|
||||
|L_t - C_{t-1}|
|
||||
\end{cases}
|
||||
$$
|
||||
|
||||
where $H$ is high, $L$ is low, and $C$ is close. The first bar uses $H_0 - L_0$ (no previous close available).
|
||||
|
||||
### 3. Average True Range (via RMA)
|
||||
|
||||
ATR uses Wilder's RMA smoothing with warmup compensation:
|
||||
|
||||
$$
|
||||
\beta = \frac{1}{\text{period}}
|
||||
$$
|
||||
|
||||
$$
|
||||
\text{RawRMA}_t = \text{RawRMA}_{t-1} \cdot (1 - \beta) + \text{TR}_t \cdot \beta
|
||||
$$
|
||||
|
||||
$$
|
||||
E_t = E_{t-1} \cdot (1 - \beta)
|
||||
$$
|
||||
|
||||
$$
|
||||
\text{ATR}_t = \frac{\text{RawRMA}_t}{1 - E_t}
|
||||
$$
|
||||
|
||||
where $E$ is the exponential decay factor that converges to 0 as the series progresses. The division compensates for warmup bias.
|
||||
|
||||
### 4. Upper and Lower Bands
|
||||
|
||||
Bands are placed symmetrically around the EMA:
|
||||
|
||||
$$
|
||||
U_t = \text{EMA}_t + \text{mult} \cdot \text{ATR}_t
|
||||
$$
|
||||
|
||||
$$
|
||||
L_t = \text{EMA}_t - \text{mult} \cdot \text{ATR}_t
|
||||
$$
|
||||
|
||||
where mult is typically 2.0. The bands expand during volatile periods and contract during consolidation.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
### EMA Warmup Compensation
|
||||
|
||||
Traditional EMA initializes with the first price and decays toward the true average:
|
||||
|
||||
$$
|
||||
\text{EMA}_t = \alpha \cdot P_t + (1 - \alpha) \cdot \text{EMA}_{t-1}
|
||||
$$
|
||||
|
||||
This produces biased early values. The warmup-compensated version tracks:
|
||||
|
||||
$$
|
||||
S_t = \sum_{i=0}^{t} P_i \cdot \alpha \cdot (1-\alpha)^{t-i}
|
||||
$$
|
||||
|
||||
$$
|
||||
W_t = \sum_{i=0}^{t} \alpha \cdot (1-\alpha)^{t-i} = 1 - (1-\alpha)^{t+1}
|
||||
$$
|
||||
|
||||
Dividing $S_t / W_t$ normalizes by the actual accumulated weight rather than assuming unit weight.
|
||||
|
||||
### RMA (Wilder's Smoothing)
|
||||
|
||||
RMA uses $\alpha = 1/\text{period}$ compared to EMA's $\alpha = 2/(\text{period}+1)$:
|
||||
|
||||
| Period | EMA α | RMA α |
|
||||
| :---: | :---: | :---: |
|
||||
| 10 | 0.1818 | 0.10 |
|
||||
| 14 | 0.1333 | 0.0714 |
|
||||
| 20 | 0.0952 | 0.05 |
|
||||
|
||||
RMA is slower/smoother than EMA for the same period. An RMA(14) roughly matches an EMA(27) in smoothness.
|
||||
|
||||
### Band Width Interpretation
|
||||
|
||||
The ATR multiplier determines how many "volatility units" away the bands sit:
|
||||
|
||||
| Multiplier | Band Width | Usage |
|
||||
| :---: | :--- | :--- |
|
||||
| 1.0 | 1 ATR | Tight—frequent touches, aggressive trading |
|
||||
| 2.0 | 2 ATR | Standard—balanced signal frequency |
|
||||
| 3.0 | 3 ATR | Wide—rare touches, conservative entry |
|
||||
|
||||
Price spending extended time outside the bands indicates strong trend momentum (continuation) or potential exhaustion (reversal), depending on context.
|
||||
|
||||
## Performance Profile
|
||||
|
||||
### Operation Count (Streaming Mode, Scalar)
|
||||
|
||||
Per-bar cost for EMA + ATR computation:
|
||||
Per-bar cost for full Keltner Channel calculation:
|
||||
|
||||
| Operation | Count | Cost (cycles) | Subtotal |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| ADD/SUB | 8 | 1 | 8 |
|
||||
| MUL | 6 | 3 | 18 |
|
||||
| CMP/MAX | 2 | 1 | 2 |
|
||||
| DIV | 2 | 15 | 30 |
|
||||
| MAX | 1 | 2 | 2 |
|
||||
| ABS | 2 | 1 | 2 |
|
||||
| FMA | 2 | 4 | 8 |
|
||||
| **Total** | **18** | — | **~36 cycles** |
|
||||
| **Total** | **21** | — | **~68 cycles** |
|
||||
|
||||
**Complexity**: O(1) per bar — both EMA and ATR use recursive IIR formulas.
|
||||
**Dominant cost**: Division operations (44% of total) for warmup compensation in both EMA and ATR.
|
||||
|
||||
### Batch Mode (SIMD/FMA Analysis)
|
||||
### Batch Mode (512 values, SIMD/FMA)
|
||||
|
||||
Both EMA and ATR are IIR filters with sequential dependencies, limiting SIMD parallelization across bars:
|
||||
Both EMA and RMA are recursive filters with sequential dependencies. SIMD applies only to independent operations:
|
||||
|
||||
| Operation | Scalar Ops | SIMD Benefit | Notes |
|
||||
| :--- | :---: | :---: | :--- |
|
||||
| EMA update | 4 | 1× | Sequential dependency |
|
||||
| ATR update | 6 | 1× | Sequential dependency |
|
||||
| Band computation | 4 | 2× | Upper/lower parallel |
|
||||
| Operation | Scalar Ops | SIMD Ops (AVX2) | Speedup |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| True Range (max/abs) | 5 | 1 | 5× |
|
||||
| Band calculation (add/mul) | 4 | 1 | 4× |
|
||||
| EMA recursion | 4 | 4 | 1× |
|
||||
| ATR recursion | 4 | 4 | 1× |
|
||||
|
||||
**Per-bar savings with FMA:**
|
||||
|
||||
| Optimization | Cycles Saved | New Total |
|
||||
| :--- | :---: | :---: |
|
||||
| EMA FMA (α×P + decay×S) | 2 | 66 |
|
||||
| RMA FMA (β×TR + decay×RMA) | 2 | 64 |
|
||||
| **Total FMA savings** | **~4 cycles** | **~64 cycles** |
|
||||
|
||||
**Batch efficiency (512 bars):**
|
||||
|
||||
| Mode | Cycles/bar | Total (512 bars) | Improvement |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| Scalar streaming | 36 | 18,432 | — |
|
||||
| Partial SIMD | ~32 | ~16,384 | **~11%** |
|
||||
| Scalar streaming | 68 | 34,816 | — |
|
||||
| FMA streaming | 64 | 32,768 | **6%** |
|
||||
|
||||
SIMD benefit is minimal due to IIR dependencies in both EMA and ATR.
|
||||
Limited improvement due to IIR recursion dependencies.
|
||||
|
||||
### Quality Metrics
|
||||
|
||||
| Metric | Score | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| **Accuracy** | 10/10 | Exact EMA and ATR calculation |
|
||||
| **Timeliness** | 8/10 | EMA provides faster response than SMA-based bands |
|
||||
| **Overshoot** | 7/10 | ATR-based bands can lag during volatility spikes |
|
||||
| **Smoothness** | 9/10 | Wilder smoothing on ATR provides stable envelope |
|
||||
| **Accuracy** | 9/10 | Warmup compensation provides early accuracy |
|
||||
| **Timeliness** | 7/10 | EMA responds faster than SMA; still lags trend changes |
|
||||
| **Overshoot** | 8/10 | ATR is stable; minimal overshoot vs std dev bands |
|
||||
| **Smoothness** | 8/10 | EMA + RMA produce smooth, continuous bands |
|
||||
|
||||
## Interpretation Details
|
||||
## Validation
|
||||
|
||||
Keltner Channels provide several analytical perspectives:
|
||||
| Library | Status | Notes |
|
||||
| :--- | :---: | :--- |
|
||||
| **TA-Lib** | N/A | No Keltner implementation |
|
||||
| **Skender** | ✅ | Structural match; minor divergence during warmup |
|
||||
| **Tulip** | N/A | No Keltner implementation |
|
||||
| **Ooples** | ❔ | Implementation exists; not fully validated |
|
||||
| **PineScript** | ✅ | Reference implementation match |
|
||||
|
||||
* **Trend identification:** Direction of the middle line (EMA) indicates the overall trend direction
|
||||
* **Overbought/oversold conditions:** Price touching or exceeding the upper band may indicate overbought conditions; touching or breaking below the lower band suggests oversold conditions
|
||||
* **Trend strength assessment:** In strong trends, price will ride along one of the bands while respecting the middle line as support/resistance
|
||||
* **Volatility measurement:** The distance between bands provides a visual representation of current market volatility
|
||||
* **Breakout confirmation:** Price breaking beyond a band after a period of contraction often signals a genuine breakout rather than a false move
|
||||
* **Mean reversion opportunities:** When price reaches or exceeds a band and then reverses back inside, it often continues toward the middle line
|
||||
* **Channel compression:** Narrowing bands indicate decreasing volatility, often preceding a significant price move
|
||||
Skender's implementation uses a different warmup approach (SMA seeding for initial values), causing 2-4% divergence during the first ~period bars. After warmup, values converge within floating-point tolerance.
|
||||
|
||||
## Limitations and Considerations
|
||||
## Common Pitfalls
|
||||
|
||||
* **Lagging component:** As an EMA-based indicator with ATR smoothing, Keltner Channels exhibit some lag
|
||||
* **Parameter sensitivity:** Results can vary significantly based on length and multiplier settings
|
||||
* **False signals:** During strong trends, touching a band does not necessarily indicate a reversal
|
||||
* **Significance of breakouts:** Not all band breaks result in significant price movements
|
||||
* **Complementary indicator:** Most effective when combined with momentum and trend confirmation tools
|
||||
* **Timeframe dependence:** Different settings may be required for different timeframes
|
||||
* **Statistical basis:** Unlike Bollinger Bands, Keltner Channels do not have a specific statistical interpretation (e.g., standard deviations)
|
||||
* **Initialization period:** Requires sufficient historical data to generate reliable bands
|
||||
1. **Warmup Period**: Keltner requires `period × 2` bars before `IsHot` becomes true. The ATR component needs its own warmup on top of the EMA warmup. Using the indicator before full warmup produces less accurate values (though warmup compensation minimizes this).
|
||||
|
||||
2. **ATR vs. Standard Deviation**: Keltner uses ATR (absolute range including gaps); Bollinger uses standard deviation (statistical dispersion). They're not interchangeable—ATR is more stable for gap-heavy instruments like futures or weekend-gapping equities.
|
||||
|
||||
3. **RMA vs. EMA for ATR**: True ATR uses Wilder's RMA smoothing ($\alpha = 1/\text{period}$), not EMA ($\alpha = 2/(\text{period}+1)$). Using EMA for ATR produces faster-reacting but less smooth bands.
|
||||
|
||||
4. **Multiplier Sensitivity**: The default multiplier of 2.0 places bands at ±2 ATR. Changing to 1.5 or 3.0 dramatically alters signal frequency. Backtest your multiplier choice—don't assume the default is optimal.
|
||||
|
||||
5. **Gap Handling**: ATR explicitly handles gaps via true range. On gap-up, TR includes $|H_t - C_{t-1}|$, expanding the channel. This is intentional—gaps represent volatility that SMA-based channels ignore.
|
||||
|
||||
6. **Memory Footprint**: The implementation stores minimal state—just the running sums/weights for EMA and ATR. Approximately 64 bytes per instance. For 5,000 symbols, budget ~320 KB.
|
||||
|
||||
7. **Bar Correction (isNew=false)**: When correcting the current bar, the indicator restores the previous state and recalculates. State consists of 6 scalar values—efficient to copy and restore.
|
||||
|
||||
## References
|
||||
|
||||
* Keltner, C. W. (1960). How to Make Money in Commodities. Kansas City, MO: Keltner Statistical Service.
|
||||
* Achelis, S. B. (2000). Technical Analysis from A to Z. McGraw-Hill.
|
||||
* Kaufman, P. J. (2013). Trading Systems and Methods (5th ed.). John Wiley & Sons.
|
||||
* Murphy, J. J. (1999). Technical Analysis of the Financial Markets. New York Institute of Finance.
|
||||
* Elder, A. (2014). The New Trading for a Living. John Wiley & Sons.
|
||||
- Keltner, C. W. (1960). *How to Make Money in Commodities*. The Keltner Statistical Service.
|
||||
- Raschke, L. B. (1995). "Keltner Channel." *Technical Analysis of Stocks & Commodities*.
|
||||
- Wilder, J. W. (1978). *New Concepts in Technical Trading Systems*. Trend Research.
|
||||
- TradingView. (2024). "Keltner Channels." Pine Script Reference Manual.
|
||||
|
||||
Reference in New Issue
Block a user