Add Savitzky-Golay Moving Average (SGMA) Indicator Implementation

- Implemented SgmaIndicator class in C# with properties for Period, Degree, and Source.
- Added unit tests for SgmaIndicator covering constructor defaults, initialization, and various update scenarios.
- Created a new Quantower adapter for the SGMA indicator, including input parameters and line series setup.
- Removed legacy SGMA implementation and tests to streamline the codebase.
- Updated project files to include new indicator and tests in the build process.
- Generated a missing indicators report and outlined a plan for oscillator documentation rewrite.
This commit is contained in:
Miha Kralj
2026-02-13 21:44:45 -08:00
parent 951842acca
commit dfeb23bf3d
81 changed files with 13629 additions and 2041 deletions
@@ -0,0 +1,136 @@
using TradingPlatform.BusinessLayer;
using QuanTAlib;
namespace QuanTAlib.Tests;
public sealed class ChandelierIndicatorTests
{
[Fact]
public void ChandelierIndicator_Constructor_SetsDefaults()
{
var indicator = new ChandelierIndicator();
Assert.Equal(22, indicator.Period);
Assert.Equal(3.0, indicator.Multiplier);
Assert.True(indicator.ShowColdValues);
Assert.Contains("CHANDELIER", indicator.Name, StringComparison.Ordinal);
Assert.False(indicator.SeparateWindow);
Assert.True(indicator.OnBackGround);
}
[Fact]
public void ChandelierIndicator_MinHistoryDepths_EqualsZero()
{
var indicator = new ChandelierIndicator { Period = 22 };
Assert.Equal(0, ChandelierIndicator.MinHistoryDepths);
IWatchlistIndicator watchlistIndicator = indicator;
Assert.Equal(0, watchlistIndicator.MinHistoryDepths);
}
[Fact]
public void ChandelierIndicator_ShortName_IncludesParameters()
{
var indicator = new ChandelierIndicator { Period = 22, Multiplier = 3.0 };
indicator.Initialize();
Assert.Contains("CHANDELIER", indicator.ShortName, StringComparison.Ordinal);
Assert.Contains("22", indicator.ShortName, StringComparison.Ordinal);
}
[Fact]
public void ChandelierIndicator_SourceCodeLink_IsValid()
{
var indicator = new ChandelierIndicator();
Assert.Contains("github.com", indicator.SourceCodeLink, StringComparison.Ordinal);
Assert.Contains("Chandelier", indicator.SourceCodeLink, StringComparison.Ordinal);
}
[Fact]
public void ChandelierIndicator_Initialize_CreatesInternalIndicator()
{
var indicator = new ChandelierIndicator { Period = 22 };
indicator.Initialize();
// After init, line series should exist (ExitLong + ExitShort)
Assert.Equal(2, indicator.LinesSeries.Count);
}
[Fact]
public void ChandelierIndicator_ProcessUpdate_HistoricalBar_ComputesValue()
{
var indicator = new ChandelierIndicator { Period = 5, Multiplier = 1.0 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
double exitLong = indicator.LinesSeries[0].GetValue(0);
double exitShort = indicator.LinesSeries[1].GetValue(0);
Assert.True(double.IsFinite(exitLong));
Assert.True(double.IsFinite(exitShort));
}
[Fact]
public void ChandelierIndicator_ProcessUpdate_NewBar_ComputesValue()
{
var indicator = new ChandelierIndicator { Period = 5, Multiplier = 1.0 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 10; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
// Simulate a new bar
indicator.HistoricalData.AddBar(now.AddMinutes(10), 110, 120, 100, 115);
var newArgs = new UpdateArgs(UpdateReason.NewBar);
indicator.ProcessUpdate(newArgs);
double exitLong = indicator.LinesSeries[0].GetValue(0);
Assert.True(double.IsFinite(exitLong));
}
[Fact]
public void ChandelierIndicator_TwoLineSeries_ArePresent()
{
var indicator = new ChandelierIndicator { Period = 5, Multiplier = 1.0 };
indicator.Initialize();
var now = DateTime.UtcNow;
for (int i = 0; i < 20; i++)
{
indicator.HistoricalData.AddBar(now.AddMinutes(i), 100 + i, 110 + i, 90 + i, 105 + i);
var args = new UpdateArgs(UpdateReason.HistoricalBar);
indicator.ProcessUpdate(args);
}
// ExitLong is index 0 (green), ExitShort is index 1 (red)
Assert.Equal(2, indicator.LinesSeries.Count);
Assert.True(double.IsFinite(indicator.LinesSeries[0].GetValue(0)));
Assert.True(double.IsFinite(indicator.LinesSeries[1].GetValue(0)));
}
[Fact]
public void ChandelierIndicator_Description_IsSet()
{
var indicator = new ChandelierIndicator();
Assert.NotNull(indicator.Description);
Assert.NotEmpty(indicator.Description);
Assert.Contains("exit", indicator.Description, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,58 @@
using System.Drawing;
using System.Runtime.CompilerServices;
using TradingPlatform.BusinessLayer;
namespace QuanTAlib;
[SkipLocalsInit]
public sealed class ChandelierIndicator : Indicator, IWatchlistIndicator
{
[InputParameter("Period", sortIndex: 0, 1, 500, 1, 0)]
public int Period { get; set; } = 22;
[InputParameter("Multiplier", sortIndex: 1, 0.1, 20.0, 0.1, 1)]
public double Multiplier { get; set; } = 3.0;
[InputParameter("Show cold values", sortIndex: 21)]
public bool ShowColdValues { get; set; } = true;
private Chandelier _indicator = null!;
private readonly LineSeries _exitLongSeries;
private readonly LineSeries _exitShortSeries;
public static int MinHistoryDepths => 0;
int IWatchlistIndicator.MinHistoryDepths => MinHistoryDepths;
public override string ShortName => $"CHANDELIER({Period},{Multiplier:F1})";
public override string SourceCodeLink => "https://github.com/mihakralj/QuanTAlib/blob/main/lib/reversals/chandelier/Chandelier.cs";
public ChandelierIndicator()
{
OnBackGround = true;
SeparateWindow = false;
Name = "CHANDELIER - Chandelier Exit";
Description = "ATR-based trailing exit indicator. Two overlay lines: ExitLong (green) for long position exits, ExitShort (red) for short position exits.";
_exitLongSeries = new LineSeries(name: "Exit Long", color: Color.Green, width: 2, style: LineStyle.Solid);
_exitShortSeries = new LineSeries(name: "Exit Short", color: Color.Red, width: 2, style: LineStyle.Solid);
AddLineSeries(_exitLongSeries);
AddLineSeries(_exitShortSeries);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnInit()
{
_indicator = new Chandelier(Period, Multiplier);
base.OnInit();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
protected override void OnUpdate(UpdateArgs args)
{
_ = _indicator.Update(this.GetInputBar(args), args.IsNewBar());
_exitLongSeries.SetValue(_indicator.ExitLong, _indicator.IsHot, ShowColdValues);
_exitShortSeries.SetValue(_indicator.ExitShort, _indicator.IsHot, ShowColdValues);
}
}
@@ -0,0 +1,490 @@
// CHANDELIER Tests - Chandelier Exit
namespace QuanTAlib.Tests;
// ── A) Constructor Validation ────────────────────────────────────────────
public sealed class ChandelierConstructorTests
{
[Fact]
public void Constructor_ZeroPeriod_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Chandelier(period: 0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_NegativePeriod_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Chandelier(period: -1));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Constructor_ZeroMultiplier_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Chandelier(multiplier: 0));
Assert.Equal("multiplier", ex.ParamName);
}
[Fact]
public void Constructor_NegativeMultiplier_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => new Chandelier(multiplier: -1.0));
Assert.Equal("multiplier", ex.ParamName);
}
[Fact]
public void Constructor_ValidDefaults_SetsProperties()
{
var ch = new Chandelier();
Assert.Equal(22, ch.Period);
Assert.Equal(3.0, ch.Multiplier);
Assert.Equal(23, ch.WarmupPeriod);
Assert.Contains("Chandelier", ch.Name, StringComparison.Ordinal);
}
[Fact]
public void Constructor_CustomParams_SetsProperties()
{
var ch = new Chandelier(period: 14, multiplier: 2.0);
Assert.Equal(14, ch.Period);
Assert.Equal(2.0, ch.Multiplier);
Assert.Equal(15, ch.WarmupPeriod);
}
}
// ── B) Basic Calculation ─────────────────────────────────────────────────
public sealed class ChandelierBasicTests
{
[Fact]
public void Update_ReturnsTValue()
{
var ch = new Chandelier(period: 3, multiplier: 1.0);
var bar = new TBar(DateTime.UtcNow, 100, 95, 98, 97, 1000);
TValue result = ch.Update(bar);
Assert.IsType<TValue>(result);
}
[Fact]
public void Update_Last_IsAccessible()
{
var ch = new Chandelier(period: 3, multiplier: 1.0);
var bar = new TBar(DateTime.UtcNow, 100, 95, 98, 97, 1000);
_ = ch.Update(bar);
Assert.True(double.IsFinite(ch.Last.Value) || double.IsNaN(ch.Last.Value));
}
[Fact]
public void Update_ExitLong_ExitShort_Accessible()
{
var ch = new Chandelier(period: 3, multiplier: 1.0);
// Feed enough bars to warm up
for (int i = 0; i < 10; i++)
{
double price = 100.0 + i;
_ = ch.Update(new TBar(DateTime.UtcNow.AddMinutes(i), price + 2, price - 2, price + 1, price, 1000));
}
Assert.True(double.IsFinite(ch.ExitLong));
Assert.True(double.IsFinite(ch.ExitShort));
}
[Fact]
public void Name_ContainsParameters()
{
var ch = new Chandelier(period: 14, multiplier: 2.5);
Assert.Contains("14", ch.Name, StringComparison.Ordinal);
Assert.Contains("2.5", ch.Name, StringComparison.Ordinal);
}
[Fact]
public void ExitLong_BelowPrice_InUptrend()
{
var ch = new Chandelier(period: 5, multiplier: 1.0);
double basePrice = 100.0;
// Steady uptrend
for (int i = 0; i < 20; i++)
{
double price = basePrice + i * 2;
_ = ch.Update(new TBar(DateTime.UtcNow.AddMinutes(i),
price + 1, price - 1, price + 0.5, price, 1000));
}
Assert.True(ch.ExitLong < 100.0 + 19 * 2, "ExitLong should be below the current price in an uptrend");
}
}
// ── C) State + Bar Correction ────────────────────────────────────────────
public sealed class ChandelierStateCorrectionTests
{
[Fact]
public void IsNew_True_AdvancesState()
{
var ch = new Chandelier(period: 3, multiplier: 1.0);
_ = ch.Update(new TBar(DateTime.UtcNow, 105, 95, 100, 100, 1000), isNew: true);
var first = ch.Last;
_ = ch.Update(new TBar(DateTime.UtcNow.AddMinutes(1), 110, 100, 105, 105, 1000), isNew: true);
var second = ch.Last;
// Second update should change (new bar)
Assert.NotEqual(first.Time, second.Time);
}
[Fact]
public void IsNew_False_CorrectionRestoresState()
{
var ch = new Chandelier(period: 3, multiplier: 1.0);
var dt = DateTime.UtcNow;
// Feed some bars to warm up
for (int i = 0; i < 5; i++)
{
double price = 100.0 + i;
_ = ch.Update(new TBar(dt.AddMinutes(i), price + 2, price - 2, price + 1, price, 1000), isNew: true);
}
// New bar
_ = ch.Update(new TBar(dt.AddMinutes(5), 110, 105, 108, 107, 1000), isNew: true);
// Correct the bar (isNew=false with different values)
_ = ch.Update(new TBar(dt.AddMinutes(5), 111, 104, 109, 108, 1000), isNew: false);
// Another correction should produce same result
_ = ch.Update(new TBar(dt.AddMinutes(5), 111, 104, 109, 108, 1000), isNew: false);
var corrected1 = ch.ExitLong;
_ = ch.Update(new TBar(dt.AddMinutes(5), 111, 104, 109, 108, 1000), isNew: false);
var corrected2 = ch.ExitLong;
Assert.Equal(corrected1, corrected2);
}
[Fact]
public void IterativeCorrections_ProduceSameResult()
{
var ch = new Chandelier(period: 3, multiplier: 1.0);
var dt = DateTime.UtcNow;
for (int i = 0; i < 5; i++)
{
double price = 100.0 + i;
_ = ch.Update(new TBar(dt.AddMinutes(i), price + 2, price - 2, price + 1, price, 1000), isNew: true);
}
// Add new bar then correct 3 times
_ = ch.Update(new TBar(dt.AddMinutes(5), 110, 100, 108, 105, 1000), isNew: true);
double[] results = new double[3];
for (int i = 0; i < 3; i++)
{
_ = ch.Update(new TBar(dt.AddMinutes(5), 112, 101, 110, 107, 1000), isNew: false);
results[i] = ch.ExitLong;
}
Assert.Equal(results[0], results[1]);
Assert.Equal(results[1], results[2]);
}
[Fact]
public void Reset_ClearsAllState()
{
var ch = new Chandelier(period: 3, multiplier: 1.0);
for (int i = 0; i < 10; i++)
{
double price = 100.0 + i;
_ = ch.Update(new TBar(DateTime.UtcNow.AddMinutes(i), price + 2, price - 2, price + 1, price, 1000));
}
Assert.True(ch.IsHot);
ch.Reset();
Assert.False(ch.IsHot);
Assert.True(double.IsNaN(ch.ExitLong));
Assert.True(double.IsNaN(ch.ExitShort));
}
}
// ── D) Warmup / Convergence ──────────────────────────────────────────────
public sealed class ChandelierWarmupTests
{
[Fact]
public void IsHot_FlipsAfterWarmup()
{
int period = 5;
var ch = new Chandelier(period: period, multiplier: 1.0);
// Feed period bars — should NOT be hot yet (ATR not seeded)
for (int i = 0; i < period; i++)
{
double price = 100.0 + i;
_ = ch.Update(new TBar(DateTime.UtcNow.AddMinutes(i), price + 2, price - 2, price + 1, price, 1000));
Assert.False(ch.IsHot, $"Should not be hot at bar {i}");
}
// Feed one more bar — ATR is now seeded, IsHot should flip
double p = 100.0 + period;
_ = ch.Update(new TBar(DateTime.UtcNow.AddMinutes(period), p + 2, p - 2, p + 1, p, 1000));
Assert.True(ch.IsHot, $"Should be hot after {period + 1} bars");
}
[Fact]
public void WarmupPeriod_EqualsPeriodPlusOne()
{
var ch = new Chandelier(period: 22, multiplier: 3.0);
Assert.Equal(23, ch.WarmupPeriod);
}
}
// ── E) Robustness ────────────────────────────────────────────────────────
public sealed class ChandelierRobustnessTests
{
[Fact]
public void NaN_Input_UsesLastValidValue()
{
var ch = new Chandelier(period: 3, multiplier: 1.0);
var dt = DateTime.UtcNow;
// Feed valid bars
for (int i = 0; i < 5; i++)
{
double price = 100.0 + i;
_ = ch.Update(new TBar(dt.AddMinutes(i), price + 2, price - 2, price + 1, price, 1000));
}
// Feed NaN bar
_ = ch.Update(new TBar(dt.AddMinutes(5), double.NaN, double.NaN, double.NaN, double.NaN, 0));
// Should still produce finite output (using last-valid substitution)
Assert.True(double.IsFinite(ch.ExitLong));
}
[Fact]
public void Infinity_Input_UsesLastValidValue()
{
var ch = new Chandelier(period: 3, multiplier: 1.0);
var dt = DateTime.UtcNow;
for (int i = 0; i < 5; i++)
{
double price = 100.0 + i;
_ = ch.Update(new TBar(dt.AddMinutes(i), price + 2, price - 2, price + 1, price, 1000));
}
_ = ch.Update(new TBar(dt.AddMinutes(5),
double.PositiveInfinity, double.NegativeInfinity, double.PositiveInfinity, double.PositiveInfinity, 0));
Assert.True(double.IsFinite(ch.ExitLong));
}
[Fact]
public void FirstBar_NaN_ReturnsNaN()
{
var ch = new Chandelier(period: 3, multiplier: 1.0);
_ = ch.Update(new TBar(DateTime.UtcNow, double.NaN, double.NaN, double.NaN, double.NaN, 0));
Assert.True(double.IsNaN(ch.Last.Value));
}
}
// ── F) Consistency ───────────────────────────────────────────────────────
public sealed class ChandelierConsistencyTests
{
private static TBarSeries CreateGbmBars(int count = 500)
{
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.20, seed: 42);
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
}
[Fact]
public void Streaming_MatchesBatch()
{
var bars = CreateGbmBars();
int period = 22;
double multiplier = 3.0;
// Streaming
var streaming = new Chandelier(period, multiplier);
var streamResults = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
_ = streaming.Update(bars[i], isNew: true);
streamResults[i] = streaming.ExitLong;
}
// Batch
var batchResults = Chandelier.Batch(bars, period, multiplier);
int warmup = period;
for (int i = warmup; i < bars.Count; i++)
{
Assert.Equal(streamResults[i], batchResults[i].Value, precision: 10);
}
}
[Fact]
public void ExitShort_GreaterOrEqual_ExitLong_Often()
{
var bars = CreateGbmBars();
var ch = new Chandelier(period: 22, multiplier: 3.0);
int aboveCount = 0;
int belowCount = 0;
for (int i = 0; i < bars.Count; i++)
{
_ = ch.Update(bars[i], isNew: true);
if (ch.IsHot)
{
if (ch.ExitShort >= ch.ExitLong)
{
aboveCount++;
}
else
{
belowCount++;
}
}
}
// Verify the indicator produces hot bars
Assert.True(aboveCount + belowCount > 0, "Should have some hot bars");
}
[Fact]
public void TValue_Update_MatchesTBar_Update()
{
var ch1 = new Chandelier(period: 5, multiplier: 1.0);
var ch2 = new Chandelier(period: 5, multiplier: 1.0);
double[] prices = [100, 102, 98, 105, 99, 103, 107, 95, 110, 108];
for (int i = 0; i < prices.Length; i++)
{
double p = prices[i];
// TBar with equal OHLC
_ = ch1.Update(new TBar(DateTime.UtcNow.AddMinutes(i), p, p, p, p, 0), isNew: true);
// TValue
_ = ch2.Update(new TValue(DateTime.UtcNow.AddMinutes(i), p), isNew: true);
}
Assert.Equal(ch1.ExitLong, ch2.ExitLong);
Assert.Equal(ch1.ExitShort, ch2.ExitShort);
}
}
// ── G) Span API Tests ────────────────────────────────────────────────────
public sealed class ChandelierSpanTests
{
[Fact]
public void Batch_Span_InvalidPeriod_Throws()
{
var ex = Assert.Throws<ArgumentException>(() =>
Chandelier.Batch(new double[10], new double[10], new double[10], new double[10], new double[10], period: 0));
Assert.Equal("period", ex.ParamName);
}
[Fact]
public void Batch_Span_MismatchedLengths_Throws()
{
var ex = Assert.Throws<ArgumentException>(() =>
Chandelier.Batch(new double[10], new double[10], new double[5], new double[10], new double[10], period: 5));
Assert.Equal("high", ex.ParamName);
}
[Fact]
public void Batch_Span_OutputTooShort_Throws()
{
var ex = Assert.Throws<ArgumentException>(() =>
Chandelier.Batch(new double[10], new double[10], new double[10], new double[10], new double[5], period: 5));
Assert.Equal("output", ex.ParamName);
}
[Fact]
public void Batch_Span_Empty_NoException()
{
var output = Array.Empty<double>();
var ex = Record.Exception(() =>
Chandelier.Batch(ReadOnlySpan<double>.Empty, ReadOnlySpan<double>.Empty,
ReadOnlySpan<double>.Empty, ReadOnlySpan<double>.Empty, output.AsSpan(), period: 5));
Assert.Null(ex);
}
}
// ── H) Event / Chainability ──────────────────────────────────────────────
public sealed class ChandelierEventTests
{
[Fact]
public void Pub_FiresOnUpdate()
{
var ch = new Chandelier(period: 3, multiplier: 1.0);
int fireCount = 0;
ch.Pub += (object? _, in TValueEventArgs _e) => { fireCount++; };
_ = ch.Update(new TBar(DateTime.UtcNow, 100, 95, 98, 97, 1000));
Assert.Equal(1, fireCount);
}
[Fact]
public void Pub_FiresOnEachUpdate()
{
var ch = new Chandelier(period: 3, multiplier: 1.0);
int fireCount = 0;
ch.Pub += (object? _, in TValueEventArgs _e) => { fireCount++; };
for (int i = 0; i < 5; i++)
{
double price = 100.0 + i;
_ = ch.Update(new TBar(DateTime.UtcNow.AddMinutes(i), price + 2, price - 2, price + 1, price, 1000));
}
Assert.Equal(5, fireCount);
}
}
// ── I) Prime Tests ───────────────────────────────────────────────────────
public sealed class ChandelierPrimeTests
{
[Fact]
public void Prime_TBarSeries_SetsState()
{
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.20, seed: 42);
var bars = gbm.Fetch(50, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
var ch = new Chandelier(period: 22, multiplier: 3.0);
ch.Prime(bars);
Assert.True(ch.IsHot);
Assert.True(double.IsFinite(ch.ExitLong));
Assert.True(double.IsFinite(ch.ExitShort));
}
[Fact]
public void Prime_EmptySource_NoException()
{
var ch = new Chandelier();
var bars = new TBarSeries();
var ex = Record.Exception(() => ch.Prime(bars));
Assert.Null(ex);
Assert.False(ch.IsHot);
}
}
@@ -0,0 +1,218 @@
// CHANDELIER Validation Tests - Chandelier Exit
// Cross-validated against Skender.Stock.Indicators ToChandelier()
using Skender.Stock.Indicators;
namespace QuanTAlib.Tests;
public sealed class ChandelierValidationTests
{
private static TBarSeries CreateGbmBars(int count = 500, int seed = 42)
{
var gbm = new GBM(startPrice: 100.0, mu: 0.05, sigma: 0.20, seed: seed);
return gbm.Fetch(count, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
}
// ── Cross-library: Skender Long ──────────────────────────────────────
[Fact]
public void StreamingMatchesSkender_ExitLong()
{
var _data = new ValidationTestData();
int period = 22;
double multiplier = 3.0;
// QuanTAlib streaming
var ch = new Chandelier(period, multiplier);
for (int i = 0; i < _data.Bars.Count; i++)
{
_ = ch.Update(_data.Bars[i], isNew: true);
}
// Skender
var skenderResults = _data.SkenderQuotes
.GetChandelier(period, multiplier, ChandelierType.Long)
.ToList();
// QuanTAlib streaming values
var ourValues = new double[_data.Bars.Count];
var streaming2 = new Chandelier(period, multiplier);
for (int i = 0; i < _data.Bars.Count; i++)
{
_ = streaming2.Update(_data.Bars[i], isNew: true);
ourValues[i] = streaming2.ExitLong;
}
// Compare warm values
int matched = 0;
for (int i = period; i < skenderResults.Count && i < _data.Bars.Count; i++)
{
if (skenderResults[i].ChandelierExit.HasValue && double.IsFinite(ourValues[i]))
{
Assert.Equal(
skenderResults[i].ChandelierExit!.Value,
ourValues[i],
precision: 8);
matched++;
}
}
Assert.True(matched > 0, "Should have matched at least one warm value");
_data.Dispose();
}
// ── Cross-library: Skender Short ─────────────────────────────────────
[Fact]
public void StreamingMatchesSkender_ExitShort()
{
var _data = new ValidationTestData();
int period = 22;
double multiplier = 3.0;
// Skender Short
var skenderResults = _data.SkenderQuotes
.GetChandelier(period, multiplier, ChandelierType.Short)
.ToList();
// QuanTAlib streaming
var ch = new Chandelier(period, multiplier);
var ourValues = new double[_data.Bars.Count];
for (int i = 0; i < _data.Bars.Count; i++)
{
_ = ch.Update(_data.Bars[i], isNew: true);
ourValues[i] = ch.ExitShort;
}
int matched = 0;
for (int i = period; i < skenderResults.Count && i < _data.Bars.Count; i++)
{
if (skenderResults[i].ChandelierExit.HasValue && double.IsFinite(ourValues[i]))
{
Assert.Equal(
skenderResults[i].ChandelierExit!.Value,
ourValues[i],
precision: 8);
matched++;
}
}
Assert.True(matched > 0, "Should have matched at least one warm value");
_data.Dispose();
}
// ── Self-Consistency: Streaming == Batch ──────────────────────────────
[Fact]
public void StreamingMatchesBatch_ExitLong()
{
var bars = CreateGbmBars();
int period = 22;
double multiplier = 3.0;
// Streaming
var streaming = new Chandelier(period, multiplier);
var streamExitLong = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
_ = streaming.Update(bars[i], isNew: true);
streamExitLong[i] = streaming.ExitLong;
}
// Batch
var batchResults = Chandelier.Batch(bars, period, multiplier);
for (int i = period; i < bars.Count; i++)
{
Assert.Equal(streamExitLong[i], batchResults[i].Value, precision: 10);
}
}
// ── Self-Consistency: Streaming == Span ───────────────────────────────
[Fact]
public void StreamingMatchesSpan_ExitLong()
{
var bars = CreateGbmBars();
int period = 22;
double multiplier = 3.0;
// Streaming
var streaming = new Chandelier(period, multiplier);
var streamExitLong = new double[bars.Count];
for (int i = 0; i < bars.Count; i++)
{
_ = streaming.Update(bars[i], isNew: true);
streamExitLong[i] = streaming.ExitLong;
}
// Span
var spanOutput = new double[bars.Count];
Chandelier.Batch(bars.OpenValues, bars.HighValues, bars.LowValues, bars.CloseValues,
spanOutput, period, multiplier);
for (int i = period; i < bars.Count; i++)
{
Assert.Equal(streamExitLong[i], spanOutput[i], precision: 10);
}
}
// ── Directional Correctness ──────────────────────────────────────────
[Fact]
public void HigherMultiplier_WidensExitGap()
{
var bars = CreateGbmBars(count: 100);
var narrow = new Chandelier(period: 22, multiplier: 1.0);
var wide = new Chandelier(period: 22, multiplier: 5.0);
for (int i = 0; i < bars.Count; i++)
{
_ = narrow.Update(bars[i], isNew: true);
_ = wide.Update(bars[i], isNew: true);
}
// Higher multiplier: ExitLong = HH - mult*ATR → goes DOWN (wider from HH)
// ExitShort = LL + mult*ATR → goes UP (wider from LL)
// So ExitLong with high mult should be lower than with low mult
Assert.True(wide.ExitLong < narrow.ExitLong,
$"Wide ExitLong ({wide.ExitLong}) should be < narrow ExitLong ({narrow.ExitLong})");
}
// ── Determinism ──────────────────────────────────────────────────────
[Fact]
public void SameInput_ProducesSameOutput()
{
var bars = CreateGbmBars(count: 200, seed: 123);
var ch1 = new Chandelier(period: 22, multiplier: 3.0);
var ch2 = new Chandelier(period: 22, multiplier: 3.0);
for (int i = 0; i < bars.Count; i++)
{
_ = ch1.Update(bars[i], isNew: true);
_ = ch2.Update(bars[i], isNew: true);
}
Assert.Equal(ch1.ExitLong, ch2.ExitLong);
Assert.Equal(ch1.ExitShort, ch2.ExitShort);
}
// ── Calculate Returns Valid Indicator ─────────────────────────────────
[Fact]
public void Calculate_ReturnsValidIndicatorAndResults()
{
var bars = CreateGbmBars(count: 100);
var (results, indicator) = Chandelier.Calculate(bars);
Assert.NotNull(results);
Assert.Equal(bars.Count, results.Count);
Assert.True(indicator.IsHot);
Assert.True(double.IsFinite(indicator.ExitLong));
Assert.True(double.IsFinite(indicator.ExitShort));
}
}
+410
View File
@@ -0,0 +1,410 @@
using System.Buffers;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace QuanTAlib;
/// <summary>
/// CHANDELIER: Chandelier Exit
/// </summary>
/// <remarks>
/// ATR-based trailing stop indicator producing two overlay lines (ExitLong, ExitShort).
/// Developed by Charles Le Beau and popularized by Alexander Elder.
///
/// Calculation:
/// <code>
/// ATR = Wilder's SMA-seeded RMA(TrueRange, period)
/// ExitLong = HighestHigh(period) - multiplier × ATR
/// ExitShort = LowestLow(period) + multiplier × ATR
/// </code>
///
/// <b>Key characteristics:</b>
/// - O(1) amortized update via MonotonicDeque sliding windows
/// - Inline SMA-seeded Wilder ATR (skips first bar's TR, matches Skender/TA-Lib convention)
/// - Dual output: ExitLong (long position exit) and ExitShort (short position exit)
/// - Default parameters: period=22, multiplier=3.0
/// </remarks>
/// <seealso href="Chandelier.md">Detailed documentation</seealso>
[SkipLocalsInit]
public sealed class Chandelier : ITValuePublisher
{
private const int DefaultPeriod = 22;
private const double DefaultMultiplier = 3.0;
private readonly int _period;
private readonly double _multiplier;
// Buffers for highest-high / lowest-low over period
private readonly double[] _hBuf;
private readonly double[] _lBuf;
private readonly MonotonicDeque _maxDequeHigh;
private readonly MonotonicDeque _minDequeLow;
private int _count;
private long _index;
[StructLayout(LayoutKind.Auto)]
private record struct State(
double PrevClose,
bool IsInitialized,
double LastValidHigh,
double LastValidLow,
double LastValidClose,
double SumTr,
double Atr);
private State _s;
private State _ps;
private readonly TBarPublishedHandler _barHandler;
/// <summary>Display name for the indicator.</summary>
public string Name { get; }
/// <summary>The lookback period.</summary>
public int Period => _period;
/// <summary>The ATR multiplier.</summary>
public double Multiplier => _multiplier;
/// <summary>Bars required for the indicator to warm up.</summary>
public int WarmupPeriod { get; }
/// <summary>Current exit level for long positions (green line).</summary>
public double ExitLong { get; private set; }
/// <summary>Current exit level for short positions (red line).</summary>
public double ExitShort { get; private set; }
/// <summary>Primary output value (ExitLong as TValue for overlay plotting).</summary>
public TValue Last { get; private set; }
/// <summary>True when enough bars have been processed for valid output.</summary>
public bool IsHot => _count > _period;
public event TValuePublishedHandler? Pub;
/// <summary>
/// Creates a Chandelier Exit indicator.
/// </summary>
/// <param name="period">Lookback period for ATR and HH/LL (default 22).</param>
/// <param name="multiplier">ATR multiplier (default 3.0).</param>
public Chandelier(int period = DefaultPeriod, double multiplier = DefaultMultiplier)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0.", nameof(period));
}
if (multiplier <= 0)
{
throw new ArgumentException("Multiplier must be greater than 0.", nameof(multiplier));
}
_period = period;
_multiplier = multiplier;
_hBuf = new double[_period];
_lBuf = new double[_period];
_maxDequeHigh = new MonotonicDeque(_period);
_minDequeLow = new MonotonicDeque(_period);
_count = 0;
_index = -1;
_s = new State(double.NaN, false, double.NaN, double.NaN, double.NaN, 0.0, 0.0);
_ps = _s;
Name = $"Chandelier({period},{multiplier:F1})";
WarmupPeriod = period + 1;
_barHandler = HandleBar;
}
/// <summary>
/// Creates a Chandelier Exit chained to a TBarSeries source.
/// </summary>
public Chandelier(TBarSeries source, int period = DefaultPeriod, double multiplier = DefaultMultiplier)
: 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 TValue Update(TBar input, bool isNew = true)
{
if (isNew)
{
_ps = _s;
_index++;
_count++;
}
else
{
_s = _ps;
}
var s = _s;
// Validate inputs — substitute last-valid on NaN/Infinity
double high = input.High;
double low = input.Low;
double close = input.Close;
if (double.IsFinite(high)) { s.LastValidHigh = high; }
else { high = s.LastValidHigh; }
if (double.IsFinite(low)) { s.LastValidLow = low; }
else { low = s.LastValidLow; }
if (double.IsFinite(close)) { s.LastValidClose = close; }
else { close = s.LastValidClose; }
// If still no valid data, return NaN
if (double.IsNaN(high) || double.IsNaN(low) || double.IsNaN(close))
{
_s = s;
Last = new TValue(input.Time, double.NaN);
PubEvent(Last, isNew);
return Last;
}
// Step 1: Compute True Range
double tr;
if (!s.IsInitialized)
{
tr = high - low;
s.IsInitialized = true;
}
else
{
double hl = high - low;
double hpc = Math.Abs(high - s.PrevClose);
double lpc = Math.Abs(low - s.PrevClose);
tr = Math.Max(hl, Math.Max(hpc, lpc));
}
if (isNew)
{
s.PrevClose = close;
}
// Step 1b: Inline SMA-seeded Wilder ATR (matches Skender/SuperTrend convention)
// Bar 1 (_count==1): skip first bar's TR for initial SMA sum
// Bars 2.._period+1: accumulate TR into SumTr, seed ATR = SumTr/period at bar _period+1
// Bars _period+2+: Wilder RMA = (prevATR * (period-1) + TR) / period
double atr;
if (_count == 1)
{
// Skip first bar's TR for SMA calculation (Skender convention)
atr = 0;
}
else if (_count <= _period + 1)
{
s.SumTr += tr;
if (_count == _period + 1)
{
s.Atr = s.SumTr / _period;
}
atr = s.Atr;
}
else
{
// Wilder RMA: (prevAtr * (period - 1) + tr) / period
double invPeriod = 1.0 / _period;
s.Atr = Math.FusedMultiplyAdd(s.Atr, 1.0 - invPeriod, tr * invPeriod);
atr = s.Atr;
}
// Step 2: Track highest-high and lowest-low over period
int bufIdx = (int)(_index % _period);
_hBuf[bufIdx] = high;
_lBuf[bufIdx] = low;
if (isNew)
{
_maxDequeHigh.PushMax(_index, high, _hBuf);
_minDequeLow.PushMin(_index, low, _lBuf);
}
else
{
_maxDequeHigh.RebuildMax(_hBuf, _index, Math.Min(_count, _period));
_minDequeLow.RebuildMin(_lBuf, _index, Math.Min(_count, _period));
}
double highestHigh = _maxDequeHigh.GetExtremum(_hBuf);
double lowestLow = _minDequeLow.GetExtremum(_lBuf);
// Step 3: Chandelier exits — no second-stage smoothing
ExitLong = highestHigh - _multiplier * atr;
ExitShort = lowestLow + _multiplier * atr;
_s = s;
Last = new TValue(input.Time, ExitLong);
PubEvent(Last, isNew);
return Last;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public TValue Update(TValue input, bool isNew = true) =>
Update(new TBar(input.Time, input.Value, input.Value, input.Value, input.Value, 0), isNew);
public TSeries Update(TBarSeries source)
{
if (source.Count == 0)
{
return new TSeries([], []);
}
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
Batch(source.OpenValues, source.HighValues, source.LowValues, source.CloseValues,
CollectionsMarshal.AsSpan(v), _period, _multiplier);
source.Times.CopyTo(CollectionsMarshal.AsSpan(t));
// Prime internal state for continued streaming
Prime(source);
var lastTime = new DateTime(source.Times[^1], DateTimeKind.Utc);
Last = new TValue(lastTime, CollectionsMarshal.AsSpan(v)[^1]);
return new TSeries(t, v);
}
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 Prime(ReadOnlySpan<double> source, TimeSpan? step = null)
{
Reset();
if (source.Length == 0)
{
return;
}
long t = DateTime.UtcNow.Ticks;
long stepTicks = (step ?? TimeSpan.FromMinutes(1)).Ticks;
for (int i = 0; i < source.Length; i++)
{
double val = source[i];
Update(new TBar(t, val, val, val, val, 0), isNew: true);
t += stepTicks;
}
}
public void Reset()
{
Array.Clear(_hBuf);
Array.Clear(_lBuf);
_maxDequeHigh.Reset();
_minDequeLow.Reset();
_count = 0;
_index = -1;
_s = new State(double.NaN, false, double.NaN, double.NaN, double.NaN, 0.0, 0.0);
_ps = _s;
ExitLong = double.NaN;
ExitShort = double.NaN;
Last = default;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static void Batch(
ReadOnlySpan<double> open,
ReadOnlySpan<double> high,
ReadOnlySpan<double> low,
ReadOnlySpan<double> close,
Span<double> output,
int period,
double multiplier = DefaultMultiplier)
{
if (period <= 0)
{
throw new ArgumentException("Period must be greater than 0.", nameof(period));
}
if (multiplier <= 0)
{
throw new ArgumentException("Multiplier must be greater than 0.", nameof(multiplier));
}
if (high.Length != low.Length || high.Length != close.Length || high.Length != open.Length)
{
throw new ArgumentException("Input spans must have the same length.", nameof(high));
}
if (output.Length < high.Length)
{
throw new ArgumentException("Output span must be at least as long as input.", nameof(output));
}
int len = high.Length;
if (len == 0)
{
return;
}
// Compute via streaming instance for correctness
var indicator = new Chandelier(period, multiplier);
long baseTime = DateTime.UtcNow.Ticks;
for (int i = 0; i < len; i++)
{
_ = indicator.Update(
new TBar(baseTime + i, open[i], high[i], low[i], close[i], 0),
isNew: true);
output[i] = indicator.ExitLong;
}
}
public static TSeries Batch(TBarSeries source, int period = DefaultPeriod, double multiplier = DefaultMultiplier)
{
if (source == null || source.Count == 0)
{
return new TSeries([], []);
}
int len = source.Count;
var t = new List<long>(len);
var v = new List<double>(len);
CollectionsMarshal.SetCount(t, len);
CollectionsMarshal.SetCount(v, len);
Batch(source.OpenValues, source.HighValues, source.LowValues, source.CloseValues,
CollectionsMarshal.AsSpan(v), period, multiplier);
source.Times.CopyTo(CollectionsMarshal.AsSpan(t));
return new TSeries(t, v);
}
public static (TSeries Results, Chandelier Indicator) Calculate(
TBarSeries source, int period = DefaultPeriod, double multiplier = DefaultMultiplier)
{
var indicator = new Chandelier(period, multiplier);
var results = indicator.Update(source);
return (results, indicator);
}
}
+135
View File
@@ -0,0 +1,135 @@
# CHANDELIER: Chandelier Exit
> "The exit is more important than the entry. Everyone knows where to get in; getting out alive is the real trick."
The Chandelier Exit computes ATR-based trailing stop levels that hang from the highest high (for longs) or rise from the lowest low (for shorts) over a lookback period. It produces two overlay lines: ExitLong (trailing stop for long positions) and ExitShort (trailing stop for short positions). Developed by Charles Le Beau and popularized by Alexander Elder. Default parameters: period 22, multiplier 3.0.
## Historical Context
Charles Le Beau introduced the Chandelier Exit concept in the early 1990s, named because it "hangs down from the ceiling" of the market, like a chandelier. The idea was formalized in his work on systematic trading exits and later popularized by Dr. Alexander Elder in *Come Into My Trading Room* (2002).
The design addresses the fundamental problem of fixed-point exits: a $2 stop on a $10 stock (20%) is aggressive, while the same $2 stop on a $200 stock (1%) is pathologically tight. By anchoring exits to ATR, the Chandelier Exit auto-calibrates to each instrument's volatility regime.
The indicator is closely related to SuperTrend (which uses ATR bands around HL2 with state flipping) and Chande Kroll Stop (which adds a second smoothing stage). Where SuperTrend provides a single trend-following line with binary state, Chandelier Exit provides two independent trailing stops without trend state. Where Chande Kroll Stop smooths through a second rolling window, Chandelier Exit provides raw ATR-offset extremes, favoring responsiveness over smoothness.
Most implementations use Wilder's ATR (SMA-seeded RMA), matching the methodology used by Skender.Stock.Indicators and TradingView's reference implementations. This QuanTAlib implementation uses inline SMA-seeded Wilder's smoothing rather than bias-compensated EMA, ensuring exact match with Skender at machine precision.
## Architecture and Physics
The computation proceeds in two stages:
### 1. True Range and ATR
True Range captures gap-adjusted volatility:
$$ TR_t = \max(H_t - L_t,\ |H_t - C_{t-1}|,\ |L_t - C_{t-1}|) $$
ATR uses SMA-seeded Wilder's RMA. The first bar's TR is skipped (set to 0). Bars 2 through $N+1$ accumulate TR into an SMA seed. After seeding:
$$ ATR_t = \frac{ATR_{t-1} \times (N-1) + TR_t}{N} $$
This is equivalent to EMA with $\alpha = 1/N$ but with an explicit SMA seed rather than bias compensation. The two approaches converge asymptotically but differ during warmup and early post-warmup bars.
### 2. Chandelier Exits
Exit levels anchor to rolling extremes offset by scaled ATR:
$$ \text{ExitLong}_t = \max(H_{t-N+1}, \ldots, H_t) - m \times ATR_t $$
$$ \text{ExitShort}_t = \min(L_{t-N+1}, \ldots, L_t) + m \times ATR_t $$
ExitLong trails below the highest high: if price falls below this level, the uptrend may be exhausted. ExitShort trails above the lowest low: if price rises above this level, the downtrend may be reversing.
### Signal Interpretation
| Condition | Interpretation |
| :--- | :--- |
| Price > ExitLong | Uptrend intact; hold long position |
| Price < ExitLong | Potential long exit; uptrend may be over |
| Price < ExitShort | Downtrend intact; hold short position |
| Price > ExitShort | Potential short exit; downtrend may be over |
| ExitLong rising | Strengthening uptrend; new highs being set |
| ExitShort falling | Strengthening downtrend; new lows being set |
## Mathematical Foundation
### Parameters
| Parameter | Symbol | Default | Range | Effect |
| :--- | :---: | :---: | :--- | :--- |
| Period | $N$ | 22 | $\geq 1$ | Lookback for ATR and rolling HH/LL window |
| Multiplier | $m$ | 3.0 | $> 0$ | ATR scaling factor; larger = wider stops |
### Warmup Period
$$ W = N $$
The indicator requires $N$ bars to establish ATR and rolling extremes. With defaults: $W = 22$. However, the SMA-seeded ATR does not produce valid values until bar $N+1$, meaning the first $N$ bars' ATR reads as 0.
### Parameter Sensitivity
**Period ($N$)**: Controls the lookback window for both ATR calculation and highest-high/lowest-low tracking. Shorter periods make exits more reactive (tighter stops) but increase whipsaw risk. Longer periods provide more stable exits but lag trend changes. Common ranges: 14-28 for equities, 7-14 for crypto.
**Multiplier ($m$)**: Directly scales stop distance. At $m = 1.0$, stops sit one ATR from the extreme. At $m = 3.0$ (default), stops allow three ATRs of breathing room. The relationship is linear: doubling $m$ doubles the stop distance from the extreme. Higher multiplier means ExitLong drops lower and ExitShort rises higher, widening the gap between them.
## Performance Profile
### Implementation Design
The implementation uses two monotonic deques for O(1) amortized rolling max/min operations (highest high, lowest low) with corresponding circular buffers. ATR is computed inline using SMA-seeded Wilder's smoothing with FMA optimization, eliminating the need for a child RMA indicator.
| Metric | Score | Notes |
| :--- | :--- | :--- |
| **Complexity** | O(1) amortized | Monotonic deque operations; O(n) worst-case per element but amortized constant |
| **Allocations** | 0 | Hot path is allocation-free; all buffers pre-allocated |
| **Warmup** | $N$ bars | 22 bars with defaults |
| **Accuracy** | 10/10 | Exact match with Skender at precision 8 |
| **Timeliness** | 7/10 | Default period of 22 introduces moderate lag |
| **Smoothness** | 6/10 | Single-stage ATR offset; no secondary smoothing |
### State Management
Internal state uses a `record struct` with local copy pattern for JIT struct promotion. The state tracks previous close (for TR calculation), ATR accumulator (SumTr), current ATR value, and last-valid substitution values for NaN/Infinity robustness. Bar correction via `isNew` flag enables same-timestamp rewrites without state corruption.
## Validation
Self-consistency validation confirms all four API modes produce identical results:
| Mode | Status | Notes |
| :--- | :--- | :--- |
| **Streaming** (`Update`) | ✅ | Bar-by-bar with `isNew` support |
| **Batch** (`Update(TBarSeries)`) | ✅ | Matches streaming output |
| **Span** (`Batch(Span)`) | ✅ | Matches streaming output |
| **Event** (`Pub` subscription) | ✅ | Matches streaming output |
| Library | Status | Notes |
| :--- | :--- | :--- |
| **QuanTAlib** | ✅ | All 4 modes self-consistent |
| **Skender** | ✅ | Exact match via `GetChandelier()` at precision 8 (both Long and Short) |
| **TA-Lib** | N/A | Not implemented |
| **Tulip** | N/A | Not implemented |
| **Ooples** | N/A | Not implemented |
Cross-validation with Skender.Stock.Indicators confirms exact numerical match for both ExitLong (`ChandelierType.Long`) and ExitShort (`ChandelierType.Short`) using `GetChandelier(22, 3.0)` with tolerance of $10^{-8}$.
## Common Pitfalls
1. **ATR smoothing method matters.** Using bias-compensated EMA (standard `Rma` class) instead of SMA-seeded Wilder RMA produces a systematic ATR offset (~0.5 for typical equity data) that persists indefinitely. This implementation uses inline SMA-seeded ATR to match Skender exactly. If you see persistent 1-2 point differences with reference implementations, check your ATR seeding method.
2. **Multiplier direction is intuitive here (unlike CKSTOP).** Increasing $m$ widens the gap between ExitLong and ExitShort. ExitLong drops lower (more room below HH); ExitShort rises higher (more room above LL). This is the opposite of Chande Kroll Stop, where higher multiplier narrows the gap.
3. **First bar's TR is skipped.** The SMA-seeded Wilder convention treats bar 1's TR as 0 and begins accumulation from bar 2. This is a seeding convention, not a bug. It matches Skender, TradingView, and most standard implementations.
4. **ExitLong is not always below price.** In a strong downtrend, the highest high over 22 bars may be far above current price, but ATR is also elevated. The ExitLong line can sit well above current price in such conditions, which is correct behavior (it signals the long position should have already been exited).
5. **No trend state.** Unlike SuperTrend which flips between bullish/bearish bands, Chandelier Exit always outputs both lines. The trader decides which line is relevant based on their position direction. This makes it more flexible but requires active interpretation.
6. **Period 22 is not arbitrary.** It represents approximately one trading month (22 business days). For crypto (24/7 markets), consider period 30. For weekly charts, period 4-5 captures one month.
7. **Gap sensitivity in thin markets.** True Range includes gap components ($|H - C_{prev}|$ and $|L - C_{prev}|$). In illiquid instruments with frequent gaps, ATR may be dominated by gap noise rather than intrabar volatility. Consider longer periods to smooth this out.
## References
- Le Beau, C., & Lucas, D. (1992). *Technical Traders Guide to Computer Analysis of the Futures Market*. McGraw-Hill.
- Elder, A. (2002). *Come Into My Trading Room: A Complete Guide to Trading*. John Wiley & Sons.
- Skender.Stock.Indicators: [`GetChandelier()`](https://dotnet.stockindicators.dev/indicators/Chandelier/)