Skip to content

Commit

Permalink
add Keltner Channel indicators (#56)
Browse files Browse the repository at this point in the history
* add Keltner Channel indicators
  • Loading branch information
DaveSkender authored Jul 7, 2020
1 parent d1be1e3 commit 7af641f
Show file tree
Hide file tree
Showing 5 changed files with 254 additions and 0 deletions.
12 changes: 12 additions & 0 deletions Indicators/Keltner/Keltner.Models.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Skender.Stock.Indicators
{

public class KeltnerResult : ResultBase
{
public decimal? UpperBand { get; set; }
public decimal? Centerline { get; set; }
public decimal? LowerBand { get; set; }
public decimal? Width { get; set; }
}

}
98 changes: 98 additions & 0 deletions Indicators/Keltner/Keltner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace Skender.Stock.Indicators
{
public static partial class Indicator
{
// DONCHIAN CHANNEL
public static IEnumerable<KeltnerResult> GetKeltner(
IEnumerable<Quote> history, int emaPeriod = 20, decimal multiplier = 2, int atrPeriod = 10)
{

// clean quotes
history = Cleaners.PrepareHistory(history);

// validate parameters
ValidateKeltner(history, emaPeriod, multiplier, atrPeriod);

// initialize
List<KeltnerResult> results = new List<KeltnerResult>();
IEnumerable<EmaResult> emaResults = GetEma(history, emaPeriod);
IEnumerable<AtrResult> atrResults = GetAtr(history, atrPeriod);
int lookbackPeriod = Math.Max(emaPeriod, atrPeriod);

decimal? prevWidth = null;

// roll through history
foreach (Quote h in history)
{
KeltnerResult result = new KeltnerResult
{
Index = (int)h.Index,
Date = h.Date
};

if (h.Index >= lookbackPeriod)
{
IEnumerable<Quote> period = history
.Where(x => x.Index > (h.Index - lookbackPeriod) && x.Index <= h.Index);

EmaResult ema = emaResults.Where(x => x.Index == h.Index).FirstOrDefault();
AtrResult atr = atrResults.Where(x => x.Index == h.Index).FirstOrDefault();

result.UpperBand = ema.Ema + multiplier * atr.Atr;
result.LowerBand = ema.Ema - multiplier * atr.Atr;
result.Centerline = ema.Ema;
result.Width = (result.Centerline == 0) ? null : (result.UpperBand - result.LowerBand) / result.Centerline;

// for next iteration
prevWidth = result.Width;
}

results.Add(result);
}

return results;
}


private static void ValidateKeltner(
IEnumerable<Quote> history, int emaPeriod, decimal multiplier, int atrPeriod)
{

// check parameters
if (emaPeriod <= 1)
{
throw new BadParameterException("EMA period must be greater than 1 for Keltner Channel.");
}

if (atrPeriod <= 1)
{
throw new BadParameterException("ATR period must be greater than 1 for Keltner Channel.");
}

if (multiplier <= 0)
{
throw new BadParameterException("Multiplier must be greater than 0 for Keltner Channel.");
}

// check history
int lookbackPeriod = Math.Max(emaPeriod, atrPeriod);
int qtyHistory = history.Count();
int minHistory = Math.Max(2 * lookbackPeriod, lookbackPeriod + 100);
if (qtyHistory < minHistory)
{
throw new BadHistoryException("Insufficient history provided for Keltner Channel. " +
string.Format("You provided {0} periods of history when at least {1} is required. "
+ "Since this uses a smoothing technique, for a lookback period of {2}, "
+ "we recommend you use at least {3} data points prior to the intended "
+ "usage date for maximum precision.",
qtyHistory, minHistory, lookbackPeriod, lookbackPeriod + 250));
}
}

}

}
58 changes: 58 additions & 0 deletions Indicators/Keltner/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Keltner Channels

Keltner Channels are based on an EMA centerline and ATR band widths.
[More info ...](https://school.stockcharts.com/doku.php?id=technical_indicators:keltner_channels)

```csharp
// usage
IEnumerable<KeltnerResult> results = Indicator.GetKeltner(history, emaPeriod, multiplier, atrPeriod);
```

## Parameters

| name | type | notes
| -- |-- |--
| `history` | IEnumerable\<[Quote](/GUIDE.md#Quote)\> | Historical Quotes data should be at any consistent frequency (day, hour, minute, etc). You must supply at least 2×`N` or `N`+100 periods of `history`, whichever is more. Since this uses a smoothing technique, we recommend you use at least `N`+250 data points prior to the intended usage date for maximum precision.
| `emaPeriod` | int | Number of periods (`E`) for the center line moving average. Must be greater than 1 to calculate; however we suggest a larger period for an appropriate sample size. Default is 20.
| `multiplier` | decimal | ATR Multiplier. Must be greater than 0. Default is 2.
| `atrPeriod` | int | Number of periods (`A`) for the center line moving average. Must be greater than 1 to calculate; however we suggest a larger period for an appropriate sample size. Default is 10.

Note: `N` is the greater of `E` or `A` periods.

## Response

```csharp
IEnumerable<KeltnerResult>
```

The first `N-1` periods will have `null` values since there's not enough data to calculate. We always return the same number of elements as there are in the historical quotes.

### KeltnerResult

| name | type | notes
| -- |-- |--
| `Index` | int | Sequence of dates
| `Date` | DateTime | Date
| `UpperBand` | decimal | Upper band of Keltner Channel
| `Centerline` | decimal | EMA of Close price
| `LowerBand` | decimal | Lower band of Keltner Channel
| `Width` | decimal | Width as percent of Centerline price. `(UpperBand-LowerBand)/Centerline`

## Example

```csharp
// fetch historical quotes from your favorite feed, in Quote format
IEnumerable<Quote> history = GetHistoryFromFeed("SPY");

// calculate Keltner(20)
IEnumerable<KeltnerResult> results = Indicator.GetKeltner(history,20,2.0,10);

// use results as needed
DateTime evalDate = DateTime.Parse("12/31/2018");
KeltnerResult result = results.Where(x=>x.Date==evalDate).FirstOrDefault();
Console.WriteLine("Upper Keltner Channel on {0} was ${1}", result.Date, result.UpperBand);
```

```bash
Upper Keltner Channel on 12/31/2018 was $262.19
```
85 changes: 85 additions & 0 deletions IndicatorsTests/Test.Keltner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Skender.Stock.Indicators;
using System;
using System.Collections.Generic;
using System.Linq;

namespace StockIndicators.Tests
{
[TestClass]
public class KeltnerTests : TestBase
{

[TestMethod()]
public void GetKeltnerTest()
{
int emaPeriod = 20;
int multiplier = 2;
int atrPeriod = 10;
int lookbackPeriod = Math.Max(emaPeriod, atrPeriod);
IEnumerable<KeltnerResult> results = Indicator.GetKeltner(history, emaPeriod, multiplier, atrPeriod);

// assertions

// proper quantities
// should always be the same number of results as there is history
Assert.AreEqual(502, results.Count());
Assert.AreEqual(502 - lookbackPeriod + 1, results.Where(x => x.Centerline != null).Count());
Assert.AreEqual(502 - lookbackPeriod + 1, results.Where(x => x.UpperBand != null).Count());
Assert.AreEqual(502 - lookbackPeriod + 1, results.Where(x => x.LowerBand != null).Count());
Assert.AreEqual(502 - lookbackPeriod + 1, results.Where(x => x.Width != null).Count());

// sample value
KeltnerResult r1 = results.Where(x => x.Date == DateTime.Parse("12/31/2018")).FirstOrDefault();
Assert.AreEqual((decimal)262.1873, Math.Round((decimal)r1.UpperBand, 4));
Assert.AreEqual((decimal)249.3519, Math.Round((decimal)r1.Centerline, 4));
Assert.AreEqual((decimal)236.5165, Math.Round((decimal)r1.LowerBand, 4));
Assert.AreEqual((decimal)0.102950, Math.Round((decimal)r1.Width, 6));

KeltnerResult r2 = results.Where(x => x.Date == DateTime.Parse("12/06/2018")).FirstOrDefault();
Assert.AreEqual((decimal)275.4260, Math.Round((decimal)r2.UpperBand, 4));
Assert.AreEqual((decimal)265.4599, Math.Round((decimal)r2.Centerline, 4));
Assert.AreEqual((decimal)255.4938, Math.Round((decimal)r2.LowerBand, 4));
Assert.AreEqual((decimal)0.075085, Math.Round((decimal)r2.Width, 6));
}


/* EXCEPTIONS */

[TestMethod()]
[ExpectedException(typeof(BadParameterException), "Bad EMA period.")]
public void BadEmaPeriod()
{
Indicator.GetKeltner(history, 1, 2, 10);
}

[TestMethod()]
[ExpectedException(typeof(BadParameterException), "Bad ATR period.")]
public void BadAtrPeriod()
{
Indicator.GetKeltner(history, 20, 2, 1);
}

[TestMethod()]
[ExpectedException(typeof(BadParameterException), "Bad multiplier.")]
public void BadMultiplier()
{
Indicator.GetKeltner(history, 20, 0, 10);
}

[TestMethod()]
[ExpectedException(typeof(BadHistoryException), "Insufficient history 100.")]
public void InsufficientHistory100()
{
Indicator.GetKeltner(history.Where(x => x.Index < 120), 20, 2, 10);
}

[TestMethod()]
[ExpectedException(typeof(BadHistoryException), "Insufficient history 250.")]
public void InsufficientHistory250()
{
Indicator.GetKeltner(history.Where(x => x.Index < 500), 20, 2, 250);
}

}
}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ IEnumerable<SmaResult> results = Indicator.GetSma(history,20);
- [Donchian Channel](/Indicators/Donchian/README.md)
- [Exponential Moving Average (EMA)](/Indicators/Ema/README.md)
- [Heikin-Ashi](/Indicators/HeikinAshi/README.md)
- [Keltner Channel](/Indicators/Keltner/README.md)
- [Moving Average Convergence/Divergence (MACD)](/Indicators/Macd/README.md)
- [On-balance Volume (OBV)](/Indicators/Obv/README.md)
- [Parabolic SAR](/Indicators/ParabolicSar/README.md)
Expand Down

0 comments on commit 7af641f

Please sign in to comment.