mirror of
https://github.com/mihakralj/QuanTAlib.git
synced 2026-08-23 04:58:08 +00:00
Merge branch 'dev' into main
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
using System.Drawing;
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using static QuanTAlib.IndicatorExtensions;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// Kc: 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 KcIndicator : 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 Kc? _indicator;
|
||||
|
||||
public int MinHistoryDepths => Period * 2;
|
||||
public override string ShortName => $"Kc({Period},{Multiplier})";
|
||||
|
||||
public KcIndicator()
|
||||
{
|
||||
Name = "Kc - Keltner Channel";
|
||||
Description = "EMA-based channel with ATR-derived band width";
|
||||
SeparateWindow = false;
|
||||
OnBackGround = true;
|
||||
}
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
_indicator = new Kc(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,412 @@
|
||||
using System.Buffers;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace QuanTAlib;
|
||||
|
||||
/// <summary>
|
||||
/// KC: 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 Kc : 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 Kc(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 = $"Kc({period},{multiplier})";
|
||||
_barHandler = HandleBar;
|
||||
|
||||
Reset();
|
||||
}
|
||||
|
||||
public Kc(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 if (double.IsFinite(_state.LastValidClose))
|
||||
{
|
||||
close = _state.LastValidClose;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Neither input nor stored value is finite - use safe default
|
||||
close = 0.0;
|
||||
}
|
||||
|
||||
if (double.IsFinite(high))
|
||||
{
|
||||
_state = _state with { LastValidHigh = high };
|
||||
}
|
||||
else if (double.IsFinite(_state.LastValidHigh))
|
||||
{
|
||||
high = _state.LastValidHigh;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Neither input nor stored value is finite - use safe default
|
||||
high = 0.0;
|
||||
}
|
||||
|
||||
if (double.IsFinite(low))
|
||||
{
|
||||
_state = _state with { LastValidLow = low };
|
||||
}
|
||||
else if (double.IsFinite(_state.LastValidLow))
|
||||
{
|
||||
low = _state.LastValidLow;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Neither input nor stored value is finite - use safe default
|
||||
low = 0.0;
|
||||
}
|
||||
|
||||
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, Kc Indicator) Calculate(TBarSeries source, int period = 20, double multiplier = 2.0)
|
||||
{
|
||||
var indicator = new Kc(source, period, multiplier);
|
||||
var results = indicator.Update(source);
|
||||
return (results, indicator);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
# KC: Keltner Channel
|
||||
|
||||
> *Keltner wraps an EMA in ATR-scaled bands — a volatility envelope that responds to both trend and range.*
|
||||
|
||||
| Property | Value |
|
||||
| ---------------- | -------------------------------- |
|
||||
| **Category** | Channel |
|
||||
| **Inputs** | OHLCV bar (TBar) |
|
||||
| **Parameters** | `period` (default 20), `multiplier` (default 2.0) |
|
||||
| **Outputs** | Multiple series (Upper, Lower) |
|
||||
| **Output range** | Tracks input |
|
||||
| **Warmup** | `period * 2` bars |
|
||||
| **PineScript** | [kc.pine](kc.pine) |
|
||||
|
||||
- Keltner Channel constructs a volatility-adaptive envelope by projecting Average True Range above and below an Exponential Moving Average center line.
|
||||
- **Similar:** [BBands](../bbands/bbands.md), [APZ](../apz/apz.md) | **Complementary:** Bollinger Band squeeze (BBands inside KC signals compression); MACD for trend direction | **Trading note:** ATR-based width adapts to true volatility including gaps; Chester Keltner's 1960 original used typical price and average range.
|
||||
- Validated against TA-Lib, Skender, and Tulip reference implementations where available.
|
||||
|
||||
Keltner Channel constructs a volatility-adaptive envelope by projecting Average True Range above and below an Exponential Moving Average center line. The channel differs from ATR Bands solely in the center line: Keltner uses EMA (faster, more responsive) while ATR Bands use SMA (more stable, more lag). The EMA center combined with ATR width creates a channel that both tracks trend and adapts to volatility, making it one of the most widely used channel indicators for trend-following and mean-reversion strategies. The implementation uses EMA with warmup compensation for accurate early values and Wilder's smoothing (RMA) for ATR.
|
||||
|
||||
## Historical Context
|
||||
|
||||
Chester Keltner introduced the original "Ten-Day Moving Average Trading Rule" in his 1960 book *How to Make Money in Commodities*. Keltner's original channel used a 10-day SMA of the "typical price" (HLC/3) as the center, with the band width based on the 10-day SMA of the daily range (High - Low, without gap adjustment).
|
||||
|
||||
Linda Bradford Raschke modernized the indicator in the 1990s by replacing the SMA center with an EMA and the simple range with Average True Range. This modern version became widely known as "Keltner Channels" and is the standard implementation in most platforms. The switch to EMA reduces lag in the center line, and the switch to ATR ensures that gaps contribute to band width — critical for futures and stocks that gap regularly. The ATR component uses Wilder's smoothing ($\alpha = 1/n$), providing infinite memory that makes the channel particularly stable after sufficient warmup.
|
||||
|
||||
## Architecture & Physics
|
||||
|
||||
### 1. Center Line (EMA with Warmup Compensation)
|
||||
|
||||
$$\alpha = \frac{2}{n + 1}$$
|
||||
|
||||
$$\text{raw}_t = \alpha \cdot x_t + (1 - \alpha) \cdot \text{raw}_{t-1}$$
|
||||
|
||||
$$w_t = \alpha + (1 - \alpha) \cdot w_{t-1}$$
|
||||
|
||||
$$\text{EMA}_t = \frac{\text{raw}_t}{w_t}$$
|
||||
|
||||
The weight accumulator $w$ compensates for EMA initialization bias, producing accurate values from bar 1.
|
||||
|
||||
### 2. True Range
|
||||
|
||||
$$TR_t = \max(H_t - L_t,\; |H_t - C_{t-1}|,\; |L_t - C_{t-1}|)$$
|
||||
|
||||
### 3. Average True Range (Wilder's Smoothing / RMA)
|
||||
|
||||
$$\alpha_{\text{atr}} = \frac{1}{n}$$
|
||||
|
||||
$$\text{raw\_rma}_t = \frac{\text{raw\_rma}_{t-1} \cdot (n-1) + TR_t}{n}$$
|
||||
|
||||
$$e_t = (1 - \alpha_{\text{atr}}) \cdot e_{t-1}$$
|
||||
|
||||
$$ATR_t = \frac{\text{raw\_rma}_t}{1 - e_t} \text{ (during warmup)}$$
|
||||
|
||||
### 4. Band Construction
|
||||
|
||||
$$\text{Upper}_t = \text{EMA}_t + k \cdot ATR_t$$
|
||||
|
||||
$$\text{Lower}_t = \text{EMA}_t - k \cdot ATR_t$$
|
||||
|
||||
### 5. Complexity
|
||||
|
||||
$O(1)$ per bar: one EMA update, one True Range computation, one RMA update, and two band calculations. No buffers required.
|
||||
|
||||
## Mathematical Foundation
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Description | Default | Constraint |
|
||||
|-----------|-------------|---------|------------|
|
||||
| `period` | Lookback for EMA and ATR smoothing ($n$) | 20 | $> 0$ |
|
||||
| `multiplier` | ATR scale factor ($k$) | 2.0 | $> 0$ |
|
||||
| `source` | Input series for EMA center | close | |
|
||||
|
||||
### Keltner vs. ATR Bands vs. Bollinger
|
||||
|
||||
| Feature | Keltner | ATR Bands | Bollinger |
|
||||
|---------|---------|-----------|-----------|
|
||||
| Center | EMA | SMA | SMA |
|
||||
| Width | ATR | ATR | StdDev |
|
||||
| Gap sensitivity | Yes (via TR) | Yes (via TR) | No |
|
||||
| Distribution assumption | None | None | Gaussian |
|
||||
|
||||
### Output Interpretation
|
||||
|
||||
| Output | Description |
|
||||
|--------|-------------|
|
||||
| `middle` | EMA center line (trend direction) |
|
||||
| `upper` | EMA + scaled ATR (dynamic resistance) |
|
||||
| `lower` | EMA - scaled ATR (dynamic support) |
|
||||
|
||||
## Performance Profile
|
||||
|
||||
### Operation Count (Streaming Mode)
|
||||
|
||||
KC combines an EMA with warmup compensation (center), True Range computation, and Wilder's RMA with warmup compensation (ATR):
|
||||
|
||||
| Operation | Count | Cost (cycles) | Subtotal |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| FMA (EMA: α×source + (1-α)×prev) | 1 | 4 | 4 |
|
||||
| FMA (weight accumulator update) | 1 | 4 | 4 |
|
||||
| DIV (raw / weight for EMA) | 1 | 15 | 15 |
|
||||
| SUB (H - L) | 1 | 1 | 1 |
|
||||
| SUB + ABS (H - prevC, L - prevC) | 2 | 2 | 4 |
|
||||
| CMP (max of 3 for TR) | 2 | 1 | 2 |
|
||||
| FMA (RMA: prev×(n-1)/n + TR/n) | 1 | 4 | 4 |
|
||||
| MUL (multiplier × ATR) | 1 | 3 | 3 |
|
||||
| ADD/SUB (EMA ± width) | 2 | 1 | 2 |
|
||||
| **Total (hot)** | **12** | — | **~39 cycles** |
|
||||
|
||||
During warmup (RMA compensator active):
|
||||
|
||||
| Operation | Count | Cost (cycles) | Subtotal |
|
||||
| :--- | :---: | :---: | :---: |
|
||||
| MUL (e × (1 - α)) | 1 | 3 | 3 |
|
||||
| SUB (1 - e) | 1 | 1 | 1 |
|
||||
| DIV (raw_rma / (1 - e)) | 1 | 15 | 15 |
|
||||
| CMP (e > ε) | 1 | 1 | 1 |
|
||||
| **Warmup overhead** | **4** | — | **~20 cycles** |
|
||||
|
||||
**Total during warmup:** ~59 cycles/bar; **Post-warmup:** ~39 cycles/bar.
|
||||
|
||||
### Batch Mode (SIMD Analysis)
|
||||
|
||||
All IIR recursions (EMA, RMA) are state-dependent, preventing SIMD parallelization across bars:
|
||||
|
||||
| Optimization | Benefit |
|
||||
| :--- | :--- |
|
||||
| FMA instructions | 3 hardware FMAs per bar |
|
||||
| True Range computation | Vectorizable in a batch pre-pass |
|
||||
| Band arithmetic | Vectorizable in a post-pass |
|
||||
| No buffers | Zero allocation; all state fits in registers |
|
||||
|
||||
## Resources
|
||||
|
||||
- **Keltner, C.** *How to Make Money in Commodities*. 1960. (Original channel concept)
|
||||
- **Raschke, L.B. & Connors, L.** *Street Smarts*. M. Gordon Publishing, 1995. (Modern EMA + ATR version)
|
||||
- **Wilder, J.W.** *New Concepts in Technical Trading Systems*. Trend Research, 1978. (ATR and Wilder's Smoothing)
|
||||
@@ -0,0 +1,57 @@
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
// © mihakralj
|
||||
//@version=6
|
||||
indicator("Keltner Channel (KC)", "KC", overlay=true)
|
||||
|
||||
//@function Calculates Keltner Channel using EMA and ATR
|
||||
//@param source Series to calculate middle line from
|
||||
//@param length Lookback period for calculations
|
||||
//@param mult ATR multiplier for band width
|
||||
//@returns tuple with [middle, upper, lower] band values
|
||||
//@optimized Uses EMA with warmup and ATR with compensator, O(1) complexity per bar
|
||||
kc(series float source, simple int length, simple float mult) =>
|
||||
if length <= 0 or mult <= 0.0
|
||||
runtime.error("Length and multiplier must be greater than 0")
|
||||
var float alpha = 2.0 / (length + 1)
|
||||
var float sum = 0.0
|
||||
var float weight = 0.0
|
||||
float ema = na
|
||||
if na(sum)
|
||||
sum := source
|
||||
weight := 1.0
|
||||
sum := sum * (1.0 - alpha) + source * alpha
|
||||
weight := weight * (1.0 - alpha) + alpha
|
||||
ema := sum / weight
|
||||
var float prevClose = close
|
||||
float tr1 = high - low
|
||||
float tr2 = math.abs(high - prevClose)
|
||||
float tr3 = math.abs(low - prevClose)
|
||||
float trueRange = math.max(tr1, tr2, tr3)
|
||||
prevClose := close
|
||||
var float EPSILON = 1e-10
|
||||
var float raw_rma = 0.0
|
||||
var float e = 1.0
|
||||
float atrValue = na
|
||||
if not na(trueRange)
|
||||
float alpha_atr = 1.0 / float(length)
|
||||
raw_rma := (raw_rma * (length - 1) + trueRange) / length
|
||||
e := (1.0 - alpha_atr) * e
|
||||
atrValue := e > EPSILON ? raw_rma / (1.0 - e) : raw_rma
|
||||
float width = mult * nz(atrValue, 0.0)
|
||||
[ema, ema + width, ema - width]
|
||||
|
||||
// ---------- Main loop ----------
|
||||
|
||||
// Inputs
|
||||
i_source = input.source(close, "Source")
|
||||
i_length = input.int(20, "Length", minval=1)
|
||||
i_mult = input.float(2.0, "ATR Multiplier", minval=0.001)
|
||||
|
||||
// Calculation
|
||||
[middle, upper, lower] = kc(i_source, i_length, i_mult)
|
||||
|
||||
// Plot
|
||||
plot(middle, "Middle", color=color.yellow, linewidth=2)
|
||||
p1 = plot(upper, "Upper", color=color.yellow, linewidth=2)
|
||||
p2 = plot(lower, "Lower", color=color.yellow, linewidth=2)
|
||||
fill(p1, p2, color=color.new(color.blue, 90), title="Band Fill")
|
||||
@@ -0,0 +1,215 @@
|
||||
using TradingPlatform.BusinessLayer;
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class KcIndicatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_SetsDefaults()
|
||||
{
|
||||
var ind = new KcIndicator();
|
||||
|
||||
Assert.Equal(20, ind.Period);
|
||||
Assert.Equal(2.0, ind.Multiplier);
|
||||
Assert.True(ind.ShowColdValues);
|
||||
Assert.Equal("Kc - Keltner Channel", ind.Name);
|
||||
Assert.False(ind.SeparateWindow);
|
||||
Assert.True(ind.OnBackGround);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinHistoryDepths_EqualsPeriodTimesTwo()
|
||||
{
|
||||
var ind = new KcIndicator { Period = 15 };
|
||||
Assert.Equal(30, ind.MinHistoryDepths); // Period * 2
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShortName_ReflectsParameters()
|
||||
{
|
||||
var ind = new KcIndicator { 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 KcIndicator { 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 KcIndicator { 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 KcIndicator { 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 KcIndicator { 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 KcIndicator { 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 KcIndicator { 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 KcIndicator { 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 KcIndicator { 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 KcIndicator { Period = 10, Multiplier = 1.0 };
|
||||
var ind2 = new KcIndicator { 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,450 @@
|
||||
using System;
|
||||
using QuanTAlib;
|
||||
using Xunit;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public class KcTests
|
||||
{
|
||||
[Fact]
|
||||
public void Kc_Constructor_ValidatesInput()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Kc(0));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Kc(-5));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Kc(10, 0.0));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => new Kc(10, -1.0));
|
||||
|
||||
var k = new Kc(10, 2.0);
|
||||
Assert.Equal(20, k.WarmupPeriod); // period * 2
|
||||
Assert.Contains("Kc", k.Name, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kc_InitialState_Defaults()
|
||||
{
|
||||
var k = new Kc(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 Kc_FirstBar_AllBandsEqualClose()
|
||||
{
|
||||
var k = new Kc(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 Kc_SecondBar_BandsExpand()
|
||||
{
|
||||
var k = new Kc(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 Kc_BandWidth_ProportionalToATR()
|
||||
{
|
||||
var k1 = new Kc(10, 1.0);
|
||||
var k2 = new Kc(10, 2.0);
|
||||
var k3 = new Kc(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 Kc_BandOrder_Correct()
|
||||
{
|
||||
var k = new Kc(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 Kc_MiddleIsEMA()
|
||||
{
|
||||
var k = new Kc(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 Kc_BandSymmetry()
|
||||
{
|
||||
var k = new Kc(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 Kc_IsHot_TurnsTrueAfterWarmup()
|
||||
{
|
||||
var k = new Kc(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 Kc_IsNewFalse_RebuildsState()
|
||||
{
|
||||
var k = new Kc(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 Kc_NaN_UsesLastValid()
|
||||
{
|
||||
var k = new Kc(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 Kc_Reset_Clears()
|
||||
{
|
||||
var k = new Kc(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 Kc_BatchVsStreaming_Match()
|
||||
{
|
||||
var kStream = new Kc(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) = Kc.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 Kc_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>(() => Kc.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), 0));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => Kc.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), -1));
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => Kc.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), 10, 0.0));
|
||||
Assert.Throws<ArgumentException>(() => Kc.Batch(highShort.AsSpan(), low.AsSpan(), close.AsSpan(), middle.AsSpan(), upper.AsSpan(), lower.AsSpan(), 2));
|
||||
Assert.Throws<ArgumentException>(() => Kc.Batch(high.AsSpan(), low.AsSpan(), close.AsSpan(), smallOut.AsSpan(), upper.AsSpan(), lower.AsSpan(), 2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kc_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];
|
||||
|
||||
Kc.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 Kc_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) = Kc.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 Kc_Event_Publishes()
|
||||
{
|
||||
var src = new TBarSeries();
|
||||
var k = new Kc(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 Kc_HighVolatility_WiderBands()
|
||||
{
|
||||
var kLow = new Kc(20, 2.0);
|
||||
var kHigh = new Kc(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 Kc_ShorterPeriod_FasterResponse()
|
||||
{
|
||||
var kShort = new Kc(5, 2.0);
|
||||
var kLong = new Kc(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 Kc_TrueRange_IncludesGaps()
|
||||
{
|
||||
var k = new Kc(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 Kc_WarmupCompensation_ReducesStartupBias()
|
||||
{
|
||||
// Warmup compensation should make early values more accurate
|
||||
var k = new Kc(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 Kc_LongSeriesStability()
|
||||
{
|
||||
var k = new Kc(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,559 @@
|
||||
using Skender.Stock.Indicators;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
using OoplesFinance.StockIndicators;
|
||||
using OoplesFinance.StockIndicators.Models;
|
||||
|
||||
namespace QuanTAlib.Tests;
|
||||
|
||||
public sealed class KcValidationTests : IDisposable
|
||||
{
|
||||
private readonly ValidationTestData _testData;
|
||||
private readonly ITestOutputHelper _output;
|
||||
private bool _disposed;
|
||||
|
||||
public KcValidationTests(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 Kc(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("Kc 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 Kc(period, multiplier);
|
||||
var (bMid, bUp, bLo) = inst.Update(_testData.Bars);
|
||||
|
||||
// Static batch
|
||||
var (sMid, sUp, sLo) = Kc.Batch(_testData.Bars, period, multiplier);
|
||||
|
||||
ValidationHelper.VerifySeriesEqual(bMid, sMid);
|
||||
ValidationHelper.VerifySeriesEqual(bUp, sUp);
|
||||
ValidationHelper.VerifySeriesEqual(bLo, sLo);
|
||||
|
||||
// Streaming
|
||||
var streaming = new Kc(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];
|
||||
Kc.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("Kc 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 Kc(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) = Kc.Batch(_testData.Bars, period, multiplier);
|
||||
|
||||
ValidationHelper.VerifySeriesEqual(bMid, evtMid);
|
||||
ValidationHelper.VerifySeriesEqual(bUp, evtUp);
|
||||
ValidationHelper.VerifySeriesEqual(bLo, evtLo);
|
||||
|
||||
_output.WriteLine("Kc eventing mode validated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Calculate_ReturnsHotIndicator()
|
||||
{
|
||||
const int period = 15;
|
||||
const double multiplier = 2.5;
|
||||
|
||||
var ((mid, up, lo), ind) = Kc.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("Kc Calculate validated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Prime_MatchesBatch()
|
||||
{
|
||||
const int period = 25;
|
||||
const double multiplier = 1.5;
|
||||
|
||||
var (bMid, bUp, bLo) = Kc.Batch(_testData.Bars, period, multiplier);
|
||||
|
||||
var primed = new Kc(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("Kc Prime validated against batch");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_LargeDataset_FiniteOutputs()
|
||||
{
|
||||
var (mid, up, lo) = Kc.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("Kc large dataset validated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_BandSymmetry_AllBars()
|
||||
{
|
||||
var ind = new Kc(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("Kc 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 Kc(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("Kc 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 Kc(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("Kc 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 Kc(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("Kc 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 Kc(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("Kc warmup compensation validated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_StateRestoration_Iterative()
|
||||
{
|
||||
var ind = new Kc(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("Kc state restoration validated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Skender_MiddleBand()
|
||||
{
|
||||
// Skender GetKeltner uses EMA center + ATR bands, same as QuanTAlib.
|
||||
// IMPORTANT: Skender defaults atrPeriods=10, but QuanTAlib uses the same period
|
||||
// for both EMA and ATR. We must pass atrPeriods=emaPeriods for exact comparison.
|
||||
// Both use warmup compensation differently, so we skip early bars.
|
||||
|
||||
int[] periods = { 5, 10, 20, 50 };
|
||||
double multiplier = 2.0;
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
var (qMiddle, _, _) = Kc.Batch(_testData.Bars, period, multiplier);
|
||||
|
||||
// Skender: atrPeriods = period to match QuanTAlib's single-period design
|
||||
var sResult = _testData.SkenderQuotes
|
||||
.GetKeltner(period, multiplier, period)
|
||||
.ToList();
|
||||
|
||||
// Compare middle band (EMA of close) using ValidationHelper
|
||||
ValidationHelper.VerifyData(qMiddle, sResult, s => s.Centerline);
|
||||
}
|
||||
_output.WriteLine("Kc middle band validated against Skender for all periods");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Skender_UpperBand()
|
||||
{
|
||||
int[] periods = { 5, 10, 20, 50 };
|
||||
double multiplier = 2.0;
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
var (_, up, _) = Kc.Batch(_testData.Bars, period, multiplier);
|
||||
|
||||
var sResult = _testData.SkenderQuotes
|
||||
.GetKeltner(period, multiplier, period)
|
||||
.ToList();
|
||||
|
||||
ValidationHelper.VerifyData(up, sResult, s => s.UpperBand);
|
||||
}
|
||||
_output.WriteLine("Kc upper band validated against Skender for all periods");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Skender_LowerBand()
|
||||
{
|
||||
int[] periods = { 5, 10, 20, 50 };
|
||||
double multiplier = 2.0;
|
||||
|
||||
foreach (var period in periods)
|
||||
{
|
||||
var (_, _, lo) = Kc.Batch(_testData.Bars, period, multiplier);
|
||||
|
||||
var sResult = _testData.SkenderQuotes
|
||||
.GetKeltner(period, multiplier, period)
|
||||
.ToList();
|
||||
|
||||
ValidationHelper.VerifyData(lo, sResult, s => s.LowerBand);
|
||||
}
|
||||
_output.WriteLine("Kc lower band validated against Skender for all periods");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Skender_BandStructure()
|
||||
{
|
||||
// Structural validation: upper > middle > lower, symmetric bands
|
||||
var period = 20;
|
||||
var multiplier = 2.0;
|
||||
|
||||
var sResult = _testData.SkenderQuotes
|
||||
.GetKeltner(period, multiplier, period)
|
||||
.ToList();
|
||||
|
||||
var (ourMid, ourUp, ourLo) = Kc.Batch(_testData.Bars, period, multiplier);
|
||||
|
||||
int warmup = period * 2;
|
||||
for (int i = warmup; i < ourMid.Count && i < sResult.Count; i++)
|
||||
{
|
||||
var sk = sResult[i];
|
||||
if (sk.UpperBand.HasValue && sk.LowerBand.HasValue && sk.Centerline.HasValue)
|
||||
{
|
||||
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}");
|
||||
Assert.True(ourUp[i].Value > ourMid[i].Value, $"Q Upper > Middle at {i}");
|
||||
Assert.True(ourLo[i].Value < ourMid[i].Value, $"Q Lower < Middle at {i}");
|
||||
}
|
||||
}
|
||||
|
||||
_output.WriteLine($"Kc vs Skender band structure validated");
|
||||
}
|
||||
|
||||
[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) = Kc.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("Kc 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 Kc(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("Kc ATR calculation validated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kc_MatchesOoples_Structural()
|
||||
{
|
||||
var gbm = new GBM(startPrice: 100.0, mu: 0.02, sigma: 0.15, seed: 42);
|
||||
var bars = gbm.Fetch(500, DateTime.UtcNow.Ticks, TimeSpan.FromMinutes(1));
|
||||
var ooplesData = bars.Select(b => new TickerData
|
||||
{
|
||||
Date = new DateTime(b.Time, DateTimeKind.Utc),
|
||||
Open = b.Open, High = b.High, Low = b.Low,
|
||||
Close = b.Close, Volume = b.Volume
|
||||
}).ToList();
|
||||
var result = new StockData(ooplesData).CalculateKeltnerChannels();
|
||||
var values = result.OutputValues.Values.First();
|
||||
int finiteCount = values.Count(v => double.IsFinite(v));
|
||||
Assert.True(finiteCount > 100, $"Expected >100 finite values, got {finiteCount}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user