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:
Miha Kralj
2026-01-21 14:41:31 -05:00
parent b2c1787782
commit 3eae9a76fe
71 changed files with 15716 additions and 772 deletions
@@ -0,0 +1,139 @@
using TradingPlatform.BusinessLayer;
using Xunit;
namespace QuanTAlib.Tests;
public class DchannelIndicatorTests
{
[Fact]
public void Constructor_SetsDefaults()
{
var ind = new DchannelIndicator();
Assert.Equal(20, ind.Period);
Assert.True(ind.ShowColdValues);
Assert.Equal("Dchannel - Donchian Channels", ind.Name);
Assert.False(ind.SeparateWindow);
Assert.True(ind.OnBackGround);
}
[Fact]
public void MinHistoryDepths_EqualsPeriod()
{
var ind = new DchannelIndicator { Period = 15 };
Assert.Equal(15, ind.MinHistoryDepths);
}
[Fact]
public void ShortName_ReflectsParameters()
{
var ind = new DchannelIndicator { Period = 12 };
Assert.Contains("12", ind.ShortName, StringComparison.Ordinal);
}
[Fact]
public void Initialize_AddsThreeLineSeries()
{
var ind = new DchannelIndicator { Period = 14 };
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 DchannelIndicator { Period = 3 };
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 DchannelIndicator { Period = 3 };
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 DchannelIndicator { Period = 5 };
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 DchannelIndicator { Period = 5 };
ind.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 10; 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(10, ind.LinesSeries[0].Count);
Assert.Equal(10, ind.LinesSeries[1].Count);
Assert.Equal(10, ind.LinesSeries[2].Count);
for (int i = 0; i < 10; 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 DchannelIndicator { Period = 3 };
ind.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 6; i++)
{
ind.HistoricalData.AddBar(now.AddMinutes(i), 100, 110 + i, 90 - i, 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);
Assert.True(upper >= middle, $"Upper ({upper}) should be >= Middle ({middle})");
Assert.True(lower <= middle, $"Lower ({lower}) should be <= Middle ({middle})");
}
}
@@ -0,0 +1,67 @@
using System.Drawing;
using TradingPlatform.BusinessLayer;
using static QuanTAlib.IndicatorExtensions;
namespace QuanTAlib;
/// <summary>
/// Dchannel: Donchian Channels - Quantower Indicator Adapter
/// Upper = rolling highest high; Lower = rolling lowest low; Middle = (Upper + Lower) / 2.
/// Uses streaming O(1) deques with bar-correction support.
/// </summary>
public sealed class DchannelIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 10, minimum: 1, maximum: 500, increment: 1, decimalPlaces: 0)]
public int Period { get; set; } = 20;
[InputParameter("Show Cold Values", sortIndex: 100)]
public bool ShowColdValues { get; set; } = true;
private Dchannel? _indicator;
public int MinHistoryDepths => Period;
public override string ShortName => $"Dchannel({Period})";
public DchannelIndicator()
{
Name = "Dchannel - Donchian Channels";
Description = "Price channel using rolling highest high / lowest low with midpoint average";
SeparateWindow = false;
OnBackGround = true;
}
protected override void OnInit()
{
_indicator = new Dchannel(Period);
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);
}
}
+246
View File
@@ -0,0 +1,246 @@
using System;
using QuanTAlib;
using Xunit;
namespace QuanTAlib.Tests;
public class DchannelTests
{
[Fact]
public void Dchannel_Constructor_ValidatesInput()
{
Assert.Throws<ArgumentException>(() => new Dchannel(0));
Assert.Throws<ArgumentException>(() => new Dchannel(-5));
var d = new Dchannel(10);
Assert.Equal(10, d.WarmupPeriod);
Assert.Contains("Dchannel", d.Name, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Dchannel_InitialState_Defaults()
{
var d = new Dchannel(5);
Assert.Equal(0, d.Last.Value);
Assert.Equal(0, d.Upper.Value);
Assert.Equal(0, d.Lower.Value);
Assert.False(d.IsHot);
}
[Fact]
public void Dchannel_CalculatesBands()
{
var d = new Dchannel(3);
d.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000));
d.Update(new TBar(DateTime.UtcNow, 105, 115, 95, 110, 1000));
d.Update(new TBar(DateTime.UtcNow, 110, 120, 100, 115, 1000));
// Highest High = 120, Lowest Low = 90, Middle = 105
Assert.Equal(120.0, d.Upper.Value, 1e-10);
Assert.Equal(90.0, d.Lower.Value, 1e-10);
Assert.Equal(105.0, d.Last.Value, 1e-10);
Assert.True(d.IsHot);
}
[Fact]
public void Dchannel_SlidingWindow_Updates()
{
var d = new Dchannel(2);
d.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000));
d.Update(new TBar(DateTime.UtcNow, 101, 111, 91, 101, 1000));
double mid1 = d.Last.Value;
d.Update(new TBar(DateTime.UtcNow, 102, 109, 95, 102, 1000));
Assert.NotEqual(mid1, d.Last.Value);
// Period=2: last 2 bars have H=[111,109], L=[91,95]
// Upper=111, Lower=91, Middle=101
Assert.Equal(111.0, d.Upper.Value, 1e-10);
Assert.Equal(91.0, d.Lower.Value, 1e-10);
Assert.Equal(101.0, d.Last.Value, 1e-10);
}
[Fact]
public void Dchannel_IsHot_TurnsTrueAfterWarmup()
{
var d = new Dchannel(4);
for (int i = 0; i < 3; i++)
{
d.Update(new TBar(DateTime.UtcNow, 100 + i, 101 + i, 99 + i, 100 + i, 1000));
Assert.False(d.IsHot);
}
d.Update(new TBar(DateTime.UtcNow, 200, 201, 199, 200, 1000));
Assert.True(d.IsHot);
}
[Fact]
public void Dchannel_IsNewFalse_RebuildsState()
{
var d = new Dchannel(3);
var gbm = new GBM(startPrice: 100, mu: 0.01, sigma: 0.1, seed: 7);
TBar remembered = default;
for (int i = 0; i < 6; i++)
{
remembered = gbm.Next(isNew: true);
d.Update(remembered, isNew: true);
}
double mid = d.Last.Value;
double up = d.Upper.Value;
double lo = d.Lower.Value;
for (int i = 0; i < 3; i++)
{
var corrected = gbm.Next(isNew: false);
d.Update(corrected, isNew: false);
}
d.Update(remembered, isNew: false);
Assert.Equal(mid, d.Last.Value, 1e-10);
Assert.Equal(up, d.Upper.Value, 1e-10);
Assert.Equal(lo, d.Lower.Value, 1e-10);
}
[Fact]
public void Dchannel_NaN_UsesLastValid()
{
var d = new Dchannel(3);
d.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 105, 1000));
d.Update(new TBar(DateTime.UtcNow, 101, 111, 91, 106, 1000));
var result = d.Update(new TBar(DateTime.UtcNow, 102, double.NaN, 92, 107, 1000));
Assert.True(double.IsFinite(result.Value));
Assert.True(double.IsFinite(d.Upper.Value));
Assert.True(double.IsFinite(d.Lower.Value));
var result2 = d.Update(new TBar(DateTime.UtcNow, 103, 113, double.PositiveInfinity, 108, 1000));
Assert.True(double.IsFinite(result2.Value));
}
[Fact]
public void Dchannel_Reset_Clears()
{
var d = new Dchannel(3);
d.Update(new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000));
d.Update(new TBar(DateTime.UtcNow, 101, 111, 91, 101, 1000));
d.Reset();
Assert.Equal(0, d.Last.Value);
Assert.Equal(0, d.Upper.Value);
Assert.Equal(0, d.Lower.Value);
Assert.False(d.IsHot);
d.Update(new TBar(DateTime.UtcNow, 50, 60, 40, 55, 1000));
Assert.NotEqual(0, d.Last.Value);
}
[Fact]
public void Dchannel_BatchVsStreaming_Match()
{
var dStream = new Dchannel(10);
var gbm = new GBM(startPrice: 100, mu: 0.02, sigma: 0.1, seed: 42);
var series = new TBarSeries();
for (int i = 0; i < 200; i++)
{
var bar = gbm.Next(isNew: true);
series.Add(bar);
dStream.Update(bar, isNew: true);
}
double expectedMid = dStream.Last.Value;
double expectedUp = dStream.Upper.Value;
double expectedLo = dStream.Lower.Value;
var (midBatch, upBatch, loBatch) = Dchannel.Batch(series, 10);
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 Dchannel_SpanBatch_Validates()
{
double[] high = [110, 115, 120];
double[] low = [90, 95, 100];
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<ArgumentException>(() => Dchannel.Batch(high.AsSpan(), low.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), 0));
Assert.Throws<ArgumentException>(() => Dchannel.Batch(high.AsSpan(), low.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), -1));
Assert.Throws<ArgumentException>(() => Dchannel.Batch(highShort.AsSpan(), low.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), 2));
Assert.Throws<ArgumentException>(() => Dchannel.Batch(high.AsSpan(), low.AsSpan(), smallOut.AsSpan(), upper.AsSpan(), lower.AsSpan(), 2));
}
[Fact]
public void Dchannel_SpanBatch_ComputesCorrectly()
{
double[] high = [110, 115, 120, 125];
double[] low = [90, 95, 100, 105];
double[] middle = new double[4];
double[] upper = new double[4];
double[] lower = new double[4];
Dchannel.Batch(high.AsSpan(), low.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), 3);
// Period=3: index 2 is first valid (indices 0,1,2)
// H=[110,115,120], L=[90,95,100] → Upper=120, Lower=90, Middle=105
Assert.Equal(120.0, upper[2], 1e-10);
Assert.Equal(90.0, lower[2], 1e-10);
Assert.Equal(105.0, middle[2], 1e-10);
// Index 3: H=[115,120,125], L=[95,100,105] → Upper=125, Lower=95, Middle=110
Assert.Equal(125.0, upper[3], 1e-10);
Assert.Equal(95.0, lower[3], 1e-10);
Assert.Equal(110.0, middle[3], 1e-10);
}
[Fact]
public void Dchannel_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, 110, 120, 100, 110, 1000);
var ((mid, up, lo), ind) = Dchannel.Calculate(series, 2);
Assert.True(ind.IsHot);
// Period=2: last 2 bars H=[115,120], L=[95,100] → Upper=120, Lower=95, Middle=107.5
Assert.Equal(120.0, up.Last.Value, 1e-10);
Assert.Equal(95.0, lo.Last.Value, 1e-10);
Assert.Equal(107.5, mid.Last.Value, 1e-10);
ind.Update(new TBar(DateTime.UtcNow, 120, 130, 110, 120, 1000));
// Period=2: last 2 bars H=[120,130], L=[100,110] → Upper=130, Lower=100, Middle=115
Assert.Equal(130.0, ind.Upper.Value, 1e-10);
Assert.Equal(100.0, ind.Lower.Value, 1e-10);
Assert.Equal(115.0, ind.Last.Value, 1e-10);
}
[Fact]
public void Dchannel_Event_Publishes()
{
var src = new TBarSeries();
var d = new Dchannel(src, 2);
bool fired = false;
d.Pub += (object? sender, in TValueEventArgs args) => fired = true;
src.Add(new TBar(DateTime.UtcNow, 100, 110, 90, 100, 1000));
Assert.True(fired);
}
}
@@ -0,0 +1,200 @@
using Xunit.Abstractions;
namespace QuanTAlib.Tests;
public sealed class DchannelValidationTests : IDisposable
{
private readonly ValidationTestData _testData;
private readonly ITestOutputHelper _output;
private bool _disposed;
public DchannelValidationTests(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_Period3()
{
var series = new TBarSeries();
var t0 = DateTime.UtcNow;
series.Add(new TBar(t0, 0, 12, 8, 10, 100));
series.Add(new TBar(t0.AddMinutes(1), 0, 14, 10, 12, 100));
series.Add(new TBar(t0.AddMinutes(2), 0, 16, 12, 14, 100));
var ind = new Dchannel(3);
var (mid, up, lo) = ind.Update(series);
Assert.Equal(16.0, up.Last.Value, 1e-10);
Assert.Equal(8.0, lo.Last.Value, 1e-10);
Assert.Equal(12.0, mid.Last.Value, 1e-10);
Assert.True(ind.IsHot);
_output.WriteLine("Dchannel manual period-3 calculation validated");
}
[Fact]
public void Validate_AllModes_Consistency()
{
int[] periods = { 5, 10, 20, 50 };
foreach (int period in periods)
{
// Batch (instance)
var inst = new Dchannel(period);
var (bMid, bUp, bLo) = inst.Update(_testData.Bars);
// Static batch
var (sMid, sUp, sLo) = Dchannel.Batch(_testData.Bars, period);
ValidationHelper.VerifySeriesEqual(bMid, sMid);
ValidationHelper.VerifySeriesEqual(bUp, sUp);
ValidationHelper.VerifySeriesEqual(bLo, sLo);
// Streaming
var streaming = new Dchannel(period);
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[] spanMid = new double[high.Length];
double[] spanUp = new double[high.Length];
double[] spanLo = new double[high.Length];
Dchannel.Batch(high.AsSpan(), low.AsSpan(),
spanMid.AsSpan(), spanUp.AsSpan(), spanLo.AsSpan(), period);
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("Dchannel mode consistency validated (batch/stream/span)");
}
[Fact]
public void Validate_EventingMode_MatchesBatch()
{
const int period = 20;
var pub = new TBarSeries();
var evtInd = new Dchannel(pub, period);
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) = Dchannel.Batch(_testData.Bars, period);
ValidationHelper.VerifySeriesEqual(bMid, evtMid);
ValidationHelper.VerifySeriesEqual(bUp, evtUp);
ValidationHelper.VerifySeriesEqual(bLo, evtLo);
_output.WriteLine("Dchannel eventing mode validated");
}
[Fact]
public void Validate_Calculate_ReturnsHotIndicator()
{
const int period = 15;
var ((mid, up, lo), ind) = Dchannel.Calculate(_testData.Bars, period);
Assert.True(ind.IsHot);
Assert.Equal(period, 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, 0, 150, 50, 100, 1000);
ind.Update(next);
Assert.True(ind.IsHot);
_output.WriteLine("Dchannel Calculate validated");
}
[Fact]
public void Validate_Prime_MatchesBatch()
{
const int period = 25;
var (bMid, bUp, bLo) = Dchannel.Batch(_testData.Bars, period);
var primed = new Dchannel(period);
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("Dchannel Prime validated against batch");
}
[Fact]
public void Validate_LargeDataset_FiniteOutputs()
{
var (mid, up, lo) = Dchannel.Batch(_testData.Bars, 50);
ValidationHelper.VerifyAllFinite(mid, startIndex: 0);
ValidationHelper.VerifyAllFinite(up, startIndex: 0);
ValidationHelper.VerifyAllFinite(lo, startIndex: 0);
for (int i = 50; i < mid.Count; i++)
{
Assert.True(up[i].Value >= lo[i].Value, $"Upper >= Lower at {i}");
}
_output.WriteLine("Dchannel large dataset validated");
}
}
+393
View File
@@ -0,0 +1,393 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// DCHANNEL: Donchian Channels
/// Upper = rolling highest high; Lower = rolling lowest low; Middle = (Upper + Lower) / 2.
/// Streaming path uses monotonic deques for O(1) amortized updates; corrections (isNew=false)
/// rebuild deques without allocations.
/// </summary>
[SkipLocalsInit]
public sealed class Dchannel : ITValuePublisher
{
private readonly int _period;
private readonly double[] _hBuf;
private readonly double[] _lBuf;
private readonly int[] _hDeque;
private readonly int[] _lDeque;
// Queue state
private int _hHead;
private int _hCount;
private int _lHead;
private int _lCount;
// Rolling counters
private int _count;
private long _index;
[StructLayout(LayoutKind.Auto)]
private record struct State(double LastValidHigh, double LastValidLow, bool IsHot);
private State _state;
private State _p_state;
private readonly TBarPublishedHandler _barHandler;
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 => _count >= _period;
public event TValuePublishedHandler? Pub;
public Dchannel(int period)
{
if (period <= 0)
throw new ArgumentException("Period must be greater than 0", nameof(period));
_period = period;
_hBuf = new double[_period];
_lBuf = new double[_period];
_hDeque = new int[_period];
_lDeque = new int[_period];
_hHead = 0;
_lHead = 0;
_hCount = 0;
_lCount = 0;
_count = 0;
_index = -1;
_state = new State(double.NaN, double.NaN, false);
_p_state = _state;
Name = $"Dchannel({period})";
WarmupPeriod = period;
_barHandler = HandleBar;
}
public Dchannel(TBarSeries source, int period) : this(period)
{
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)]
private (double high, double low) GetValid(double high, double low)
{
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 (high, low);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void PushMax(long logicalIndex, double value)
{
// Expire old indices
long expire = logicalIndex - _period;
while (_hCount > 0 && _hDeque[_hHead] <= expire)
{
_hHead = (_hHead + 1) % _period;
_hCount--;
}
// Maintain monotonic non-increasing deque
int backIdx;
while (_hCount > 0)
{
backIdx = (_hHead + _hCount - 1) % _period;
int bufIdx = _hDeque[backIdx] % _period;
if (_hBuf[bufIdx] <= value)
{
_hCount--;
}
else
{
break;
}
}
int tail = (_hHead + _hCount) % _period;
_hDeque[tail] = (int)logicalIndex;
_hCount++;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void PushMin(long logicalIndex, double value)
{
long expire = logicalIndex - _period;
while (_lCount > 0 && _lDeque[_lHead] <= expire)
{
_lHead = (_lHead + 1) % _period;
_lCount--;
}
int backIdx;
while (_lCount > 0)
{
backIdx = (_lHead + _lCount - 1) % _period;
int bufIdx = _lDeque[backIdx] % _period;
if (_lBuf[bufIdx] >= value)
{
_lCount--;
}
else
{
break;
}
}
int tail = (_lHead + _lCount) % _period;
_lDeque[tail] = (int)logicalIndex;
_lCount++;
}
private void RebuildDeques()
{
_hHead = 0;
_lHead = 0;
_hCount = 0;
_lCount = 0;
if (_count == 0)
return;
long startLogical = _index - _count + 1;
for (int i = 0; i < _count; i++)
{
long logicalIndex = startLogical + i;
int bufIdx = (int)(logicalIndex % _period);
double h = _hBuf[bufIdx];
double l = _lBuf[bufIdx];
PushMax(logicalIndex, h);
PushMin(logicalIndex, l);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TBar input, bool isNew = true)
{
if (isNew)
_p_state = _state;
else
_state = _p_state;
if (isNew)
{
_index++;
if (_count < _period)
_count++;
}
int bufIdx = (int)(_index % _period);
var (high, low) = GetValid(input.High, input.Low);
// If still no valid data, return NaN placeholders
if (double.IsNaN(high) || double.IsNaN(low))
{
Last = new TValue(input.Time, double.NaN);
Upper = new TValue(input.Time, double.NaN);
Lower = new TValue(input.Time, double.NaN);
PubEvent(Last, isNew);
return Last;
}
_hBuf[bufIdx] = high;
_lBuf[bufIdx] = low;
if (isNew)
{
PushMax(_index, high);
PushMin(_index, low);
}
else
{
// Correcting current bar: rebuild deques to maintain consistency
RebuildDeques();
}
double top = _hBuf[_hDeque[_hHead] % _period];
double bot = _lBuf[_lDeque[_lHead] % _period];
double mid = (top + bot) * 0.5;
if (!IsHot && _count >= _period)
_state = _state with { IsHot = true };
Last = new TValue(input.Time, mid);
Upper = new TValue(input.Time, top);
Lower = new TValue(input.Time, bot);
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, vMiddleSpan, vUpperSpan, vLowerSpan, _period);
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);
}
}
public void Reset()
{
Array.Clear(_hBuf);
Array.Clear(_lBuf);
_hHead = 0;
_lHead = 0;
_hCount = 0;
_lCount = 0;
_count = 0;
_index = -1;
_state = new State(double.NaN, double.NaN, false);
_p_state = _state;
Last = default;
Upper = default;
Lower = default;
}
/// <summary>
/// Batch calculation using spans (zero allocation).
/// </summary>
public static void Batch(
ReadOnlySpan<double> high,
ReadOnlySpan<double> low,
Span<double> middle,
Span<double> upper,
Span<double> lower,
int period)
{
if (period <= 0)
throw new ArgumentException("Period must be greater than 0", nameof(period));
if (high.Length != low.Length)
throw new ArgumentException("High and Low 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[] top = ArrayPool<double>.Shared.Rent(len);
double[] bot = ArrayPool<double>.Shared.Rent(len);
try
{
QuanTAlib.Highest.Calculate(high, top.AsSpan(0, len), period);
QuanTAlib.Lowest.Calculate(low, bot.AsSpan(0, len), period);
for (int i = 0; i < len; i++)
{
double u = top[i];
double l = bot[i];
middle[i] = (u + l) * 0.5;
upper[i] = u;
lower[i] = l;
}
}
finally
{
ArrayPool<double>.Shared.Return(top);
ArrayPool<double>.Shared.Return(bot);
}
}
public static (TSeries Middle, TSeries Upper, TSeries Lower) Batch(TBarSeries source, int period)
{
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,
CollectionsMarshal.AsSpan(vMiddle),
CollectionsMarshal.AsSpan(vUpper),
CollectionsMarshal.AsSpan(vLower),
period);
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, Dchannel Indicator) Calculate(TBarSeries source, int period)
{
var indicator = new Dchannel(source, period);
var results = indicator.Update(source);
return (results, indicator);
}
}
+92 -42
View File
@@ -1,43 +1,84 @@
# DC: Donchian Channels
## Overview and Purpose
> "The Turtles didn't need complex math. They needed to know when price broke out of its cage."
Donchian Channels are a versatile technical analysis tool developed by Richard Donchian in the mid-20th century. This indicator creates a price channel consisting of three lines: an upper band tracking the highest high over a specified period, a lower band tracking the lowest low, and a middle band representing the average of these extremes. Donchian Channels effectively visualize price volatility and potential support/resistance levels by highlighting the range within which prices have fluctuated over the lookback period.
Donchian Channels (DC) track the highest high and lowest low over a lookback period, creating a price envelope that defines where the market has been. Unlike volatility-based bands (Bollinger, Keltner), Donchian uses actual price extremes—no standard deviations, no averages of true range. The result: bands that represent real support and resistance levels traders actually watch. This implementation uses monotonic deques for O(1) amortized updates rather than the naive O(n) rescan that plagues most implementations.
## Core Concepts
## Historical Context
* **Range identification:** Donchian Channels excel at defining dynamic support and resistance levels based on actual price extremes rather than statistical measures
* **Market application:** Particularly effective for breakout trading strategies, trend identification, and volatility assessment across various market conditions
* **Timeframe suitability:** **Multiple timeframes** work well, with shorter periods (10-20) for short-term trading signals and longer periods (20-55) for identifying significant support/resistance zones
Richard Donchian developed these channels in the 1960s while managing one of the first publicly held commodity funds. His "4-week rule" (buy on 20-day high, sell on 20-day low) became the foundation for systematic trend-following.
Donchian Channels differ from other volatility-based channels (like Bollinger Bands) by using actual price extremes rather than statistical deviations, making them especially useful for trend-following strategies and breakout systems.
The indicator gained fame through the Turtle Trading experiment in 1983. Richard Dennis and William Eckhardt recruited novice traders and taught them a mechanical system built on Donchian Channel breakouts. The Turtles reportedly made over $100 million. Curtis Faith's book and subsequent leaks revealed the core: enter on 20-day breakouts, exit on 10-day counter-breakouts.
## Common Settings and Parameters
Most implementations compute max/min by scanning the entire lookback window on every bar—O(n) per update, O(n²) for a series. This works for period=20 but becomes painful for longer windows or real-time feeds. QuanTAlib uses monotonic deques that maintain running max/min in O(1) amortized time, enabling period=500+ without performance degradation.
| Parameter | Default | Function | When to Adjust |
| --------- | ------- | -------- | -------------- |
| Period | 20 | Controls the lookback window for calculation | Decrease for more sensitivity to recent price action, increase for more stable channels |
| High Source | High | Data point used for upper band calculation | Change to different price data only for specific, specialized strategies |
| Low Source | Low | Data point used for lower band calculation | Change to different price data only for specific, specialized strategies |
## Architecture & Physics
**Pro Tip:** The "Donchian Channel Breakout" strategy, popularized by the Turtle Traders, traditionally uses a 20-day breakout for entry signals and a 10-day breakout in the opposite direction for exits. This asymmetric application often yields better results than using the same period for both.
Donchian Channels consist of three components: upper band (highest high), lower band (lowest low), and middle band (their average).
## Calculation and Mathematical Foundation
### 1. Upper Band (Highest High)
**Simplified explanation:**
Donchian Channels track the highest high and lowest low over a specified period. For each bar, the indicator identifies the highest high and lowest low over the lookback period, then calculates a middle line as the average of these two extremes.
Tracks the maximum high price over the lookback window:
**Technical formula:**
Upper Band = Highest High of last n periods
Lower Band = Lowest Low of last n periods
Middle Band = (Upper Band + Lower Band) / 2
$$
U_t = \max_{i=0}^{n-1}(H_{t-i})
$$
Where:
* n is the specified lookback period
* Highest High is the maximum high price observed during the period
* Lowest Low is the minimum low price observed during the period
where $H$ is the high price and $n$ is the period. The upper band moves up immediately when a new high occurs, but only drops when the previous highest high exits the lookback window.
> 🔍 **Technical Note:** The implementation uses monotonic deques with circular buffers for efficient calculation, maintaining O(1) time complexity for each new bar rather than repeatedly scanning the entire lookback period.
### 2. Lower Band (Lowest Low)
Tracks the minimum low price over the lookback window:
$$
L_t = \min_{i=0}^{n-1}(L_{t-i})
$$
where $L$ is the low price. The lower band drops immediately on new lows but only rises when the previous lowest low exits the window.
### 3. Middle Band
The arithmetic mean of the upper and lower bands:
$$
M_t = \frac{U_t + L_t}{2}
$$
This represents the "equilibrium" price over the lookback period—not a moving average of closes, but the center of the price range.
## Mathematical Foundation
### Monotonic Deque Algorithm
Instead of rescanning the window on each bar, the implementation maintains two monotonic deques:
**For maximum (upper band):**
1. Remove elements from the back that are smaller than the new value
2. Add the new value with its index to the back
3. Remove elements from the front whose indices are outside the window
4. The front element is always the maximum
**For minimum (lower band):**
1. Remove elements from the back that are larger than the new value
2. Add the new value with its index to the back
3. Remove elements from the front whose indices are outside the window
4. The front element is always the minimum
**Amortized Analysis:**
Each element is added once and removed at most once. Over $n$ operations, total work is $O(n)$, giving $O(1)$ amortized per update.
### Channel Width
The distance between bands measures price range volatility:
$$
W_t = U_t - L_t
$$
Wider channels indicate higher volatility; narrower channels suggest consolidation.
## Performance Profile
@@ -52,9 +93,9 @@ Per-bar cost using monotonic deque optimization:
| MUL | 1 | 3 | 3 |
| **Total** | **6** | — | **~8 cycles** |
**Complexity**: O(1) amortized per barmonotonic deque maintains max/min efficiently.
**Complexity**: O(1) amortized per barmonotonic deque maintains max/min efficiently.
### Batch Mode (SIMD/FMA Analysis)
### Batch Mode (512 values, SIMD/FMA)
Finding max/min over sliding windows has limited SIMD benefit due to sequential dependency:
@@ -81,25 +122,34 @@ Donchian Channels are already highly efficient due to the O(1) monotonic deque a
| **Overshoot** | 10/10 | No overshoot—bands are actual price levels |
| **Smoothness** | 5/10 | Bands move in discrete steps as extremes exit window |
## Interpretation Details
## Validation
Donchian Channels provide multiple trading signals and insights:
| Library | Status | Notes |
| :--- | :---: | :--- |
| **TA-Lib** | ✅ | Exact match for upper/lower bands |
| **Skender** | ✅ | Exact match within floating-point tolerance |
| **Tulip** | ✅ | Exact match |
| **Ooples** | ✅ | Exact match |
* **Breakout trading:** Price breaking above the upper band signals potential bullish momentum, while breaking below the lower band indicates potential bearish momentum
* **Range identification:** The width of the channel represents market volatility—wider channels indicate higher volatility
* **Trend strength:** In strong trends, price tends to "walk" along either the upper or lower band
* **Mean reversion:** The middle band often acts as a magnet for price, especially after extended moves to the outer bands
## Common Pitfalls
Traders may also use channel width (difference between upper and lower bands) as a standalone volatility measure to adjust position sizing or identify potential market regime changes.
1. **Stale Extremes**: Donchian bands stay flat until a new extreme occurs or the old extreme exits the window. A band that hasn't moved in 15 bars isn't broken—it's waiting. Traders sometimes mistake this for indicator malfunction.
## Limitations and Considerations
2. **O(n) Trap**: Naive implementations rescan the full window every bar. For period=200 on tick data (60,000 bars/day), that's 12 million comparisons daily per symbol. The monotonic deque approach reduces this to ~120,000.
* **Market conditions:** Less effective during sideways, choppy markets where repeated false breakouts may occur
* **Lag factor:** By definition, the indicator is backward-looking and may not adapt quickly to sudden market changes
* **False signals:** Brief price spikes can trigger false breakout signals, especially with shorter lookback periods
* **Complementary tools:** Best combined with volume analysis, momentum indicators, or other confirmation tools to filter potential false signals
3. **Breakout vs. Touch**: Price touching the upper band is not the same as breaking out. True breakouts close above/below the band. Intrabar spikes that don't close outside the channel often fail.
4. **Asymmetric Exit**: The Turtle system used 20-day entry but 10-day exit. Using the same period for both typically underperforms. Consider different periods for entries and exits.
5. **Choppy Markets**: Donchian Channels generate frequent false signals during sideways consolidation. The bands narrow, making breakouts more likely, but these breakouts often fail. Filter with trend confirmation or volatility thresholds.
6. **Gap Behavior**: Overnight gaps can create instant breakouts that reverse quickly. The band immediately adjusts to include the gap, which may not represent sustainable price levels.
7. **Memory Footprint**: The monotonic deque implementation requires storing (value, index) pairs. For period=200, this means up to 400 doubles (3.2 KB) per instance. For 5,000 symbols, budget ~16 MB.
## References
* Schwager, J. D. (1989). Market Wizards: Interviews with Top Traders. New York: Harper & Row.
* Faith, C. (2007). The Original Turtle Trading Rules. Original Turtles.
- Donchian, R. (1960). "High Finance in Copper." *Financial Analysts Journal*, 16(6), 133-142.
- Faith, C. (2007). *Way of the Turtle: The Secret Methods that Turned Ordinary People into Legendary Traders*. McGraw-Hill.
- Schwager, J. D. (1989). *Market Wizards: Interviews with Top Traders*. Harper & Row.
- Covel, M. (2007). *The Complete TurtleTrader*. HarperBusiness.