Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions strategy/volatility/keltner_channel_strategy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) 2021-2026 Onur Cinar.
// The source code is provided under GNU AGPLv3 License.
// https://github.com/cinar/indicator

package volatility

import (
"github.com/cinar/indicator/v2/asset"
"github.com/cinar/indicator/v2/helper"
"github.com/cinar/indicator/v2/strategy"
"github.com/cinar/indicator/v2/volatility"
)

// KeltnerChannelStrategy represents the configuration parameters for calculating the Keltner Channel strategy.
// A closing above the upper band suggests a Sell signal, while a closing below the lower band suggests a Buy signal.
type KeltnerChannelStrategy struct {
// KeltnerChannel represents the configuration parameters for calculating the Keltner Channel.
KeltnerChannel *volatility.KeltnerChannel[float64]
}

// NewKeltnerChannelStrategy function initializes a new Keltner Channel strategy instance.
func NewKeltnerChannelStrategy() *KeltnerChannelStrategy {
return &KeltnerChannelStrategy{
KeltnerChannel: volatility.NewKeltnerChannel[float64](),
}
}

// Name returns the name of the strategy.
func (*KeltnerChannelStrategy) Name() string {
return "Keltner Channel Strategy"
}

// Compute processes the provided asset snapshots and generates a stream of actionable recommendations.
func (k *KeltnerChannelStrategy) Compute(snapshots <-chan *asset.Snapshot) <-chan strategy.Action {
snapshotsSplice := helper.Duplicate(snapshots, 4)

highs := asset.SnapshotsAsHighs(snapshotsSplice[0])
lows := asset.SnapshotsAsLows(snapshotsSplice[1])
closings := asset.SnapshotsAsClosings(snapshotsSplice[2])

uppers, middles, lowers := k.KeltnerChannel.Compute(highs, lows, closings)
go helper.Drain(middles)

closings2 := helper.Skip(asset.SnapshotsAsClosings(snapshotsSplice[3]), k.KeltnerChannel.IdlePeriod())

actions := helper.Operate3(uppers, lowers, closings2, func(upper, lower, closing float64) strategy.Action {
if closing > upper {
return strategy.Sell
}

if closing < lower {
return strategy.Buy
}

return strategy.Hold
})

// Keltner Channel starts only after a full period.
actions = helper.Shift(actions, k.KeltnerChannel.IdlePeriod(), strategy.Hold)

return actions
}

// Report processes the provided asset snapshots and generates a report annotated with the recommended actions.
func (k *KeltnerChannelStrategy) Report(c <-chan *asset.Snapshot) *helper.Report {
//
// snapshots[0] -> dates
// snapshots[1] -> highs -|
// snapshots[2] -> lows -+-> KeltnerChannel.Compute -> upper, middle, lower
// snapshots[3] -> closings-|
// closings -> close
// snapshots[4] -> actions -> annotations
// -> outcomes
//
snapshots := helper.Duplicate(c, 5)

dates := asset.SnapshotsAsDates(snapshots[0])
highs := asset.SnapshotsAsHighs(snapshots[1])
lows := asset.SnapshotsAsLows(snapshots[2])
closings := helper.Duplicate(asset.SnapshotsAsClosings(snapshots[3]), 2)

uppers, middles, lowers := k.KeltnerChannel.Compute(highs, lows, closings[0])
uppers = helper.Shift(uppers, k.KeltnerChannel.IdlePeriod(), 0)
middles = helper.Shift(middles, k.KeltnerChannel.IdlePeriod(), 0)
lowers = helper.Shift(lowers, k.KeltnerChannel.IdlePeriod(), 0)

actions, outcomes := strategy.ComputeWithOutcome(k, snapshots[4])
annotations := strategy.ActionsToAnnotations(actions)
outcomes = helper.MultiplyBy(outcomes, 100)

report := helper.NewReport(k.Name(), dates)
report.AddChart()

report.AddColumn(helper.NewNumericReportColumn("Close", closings[1]))
report.AddColumn(helper.NewNumericReportColumn("Upper", uppers))
report.AddColumn(helper.NewNumericReportColumn("Middle", middles))
report.AddColumn(helper.NewNumericReportColumn("Lower", lowers))
report.AddColumn(helper.NewAnnotationReportColumn(annotations))

report.AddColumn(helper.NewNumericReportColumn("Outcome", outcomes), 1)

return report
}
55 changes: 55 additions & 0 deletions strategy/volatility/keltner_channel_strategy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright (c) 2021-2026 Onur Cinar.
// The source code is provided under GNU AGPLv3 License.
// https://github.com/cinar/indicator

package volatility_test

import (
"testing"

"github.com/cinar/indicator/v2/asset"
"github.com/cinar/indicator/v2/helper"
"github.com/cinar/indicator/v2/strategy"
"github.com/cinar/indicator/v2/strategy/volatility"
)

func TestKeltnerChannelStrategy(t *testing.T) {
snapshots, err := helper.ReadFromCsvFile[asset.Snapshot]("testdata/brk-b.csv")
if err != nil {
t.Fatal(err)
}

results, err := helper.ReadFromCsvFile[strategy.Result]("testdata/keltner_channel_strategy.csv")
if err != nil {
t.Fatal(err)
}

expected := helper.Map(results, func(r *strategy.Result) strategy.Action { return r.Action })

kc := volatility.NewKeltnerChannelStrategy()
actual := kc.Compute(snapshots)

err = helper.CheckEquals(actual, expected)
if err != nil {
t.Fatal(err)
}
}

func TestKeltnerChannelStrategyReport(t *testing.T) {
snapshots, err := helper.ReadFromCsvFile[asset.Snapshot]("testdata/brk-b.csv")
if err != nil {
t.Fatal(err)
}

kc := volatility.NewKeltnerChannelStrategy()

report := kc.Report(snapshots)

fileName := "keltner_channel_strategy.html"
defer helper.Remove(t, fileName)

err = report.WriteToFile(fileName)
if err != nil {
t.Fatal(err)
}
}
Loading
Loading