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
15 changes: 14 additions & 1 deletion trend/bop.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import (
// between the two forces.
//
// Formula: BOP = (Closing - Opening) / (High - Low)
//
// A zero-range bar (High == Low) also forces Open == Close == High == Low,
// so the numerator is zero too; BOP is defined as 0 (equilibrium, its
// natural centered value) instead of propagating the resulting 0/0 NaN.
type Bop[T helper.Float] struct{}

// NewBop function initializes a new BOP instance
Expand All @@ -28,7 +32,16 @@ func NewBop[T helper.Float]() *Bop[T] {
// ComputeWithContext processes a channel of open, high, low, and close values,
// computing the BOP for each entry.
func (i *Bop[T]) ComputeWithContext(ctx context.Context, opening, high, low, closing <-chan T) <-chan T {
return helper.DivideWithContext(ctx, helper.SubtractWithContext(ctx, closing, opening), helper.SubtractWithContext(ctx, high, low))
numerator := helper.SubtractWithContext(ctx, closing, opening)
denominator := helper.SubtractWithContext(ctx, high, low)

return helper.OperateWithContext(ctx, numerator, denominator, func(num, denom T) T {
if denom == 0 {
return 0
}

return num / denom
})
}

// IdlePeriod is the initial period that BOP won't yield any results.
Expand Down
20 changes: 20 additions & 0 deletions trend/bop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,26 @@ func TestBop(t *testing.T) {
}
}

func TestBopFlatBar(t *testing.T) {
opening := helper.SliceToChan([]float64{100, 100, 100})
high := helper.SliceToChan([]float64{100, 100, 100})
low := helper.SliceToChan([]float64{100, 100, 100})
closing := helper.SliceToChan([]float64{100, 100, 100})

bop := trend.NewBop[float64]()
actual := helper.ChanToSlice(bop.Compute(opening, high, low, closing))

if len(actual) == 0 {
t.Fatal("expected at least one BOP value")
}

for _, v := range actual {
if v != 0 {
t.Fatalf("expected BOP of 0 for a zero-range bar, got %v", v)
}
}
}

func TestBopString(t *testing.T) {
expected := "BOP"
actual := trend.NewBop[float64]().String()
Expand Down
20 changes: 13 additions & 7 deletions trend/cfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ const (
//
// CFO = ((Price - Forecast) / Price) * 100
//
// A zero closing price is a degenerate/theoretical input for real
// securities; CFO is defined as 0 (no forecast deviation to report)
// instead of propagating a 0/0 NaN.
//
// Example:
//
// cfo := trend.NewCfo[float64]()
Expand Down Expand Up @@ -53,13 +57,15 @@ func (c *Cfo[T]) ComputeWithContext(ctx context.Context, closing <-chan T) <-cha

closingPriceSplice := helper.DuplicateWithContext(ctx, helper.SkipWithContext(ctx, closingSplices[2], c.IdlePeriod()), 2)

return helper.MultiplyByWithContext(ctx, helper.DivideWithContext(ctx, helper.SubtractWithContext(ctx, closingPriceSplice[0],
forecast,
),
closingPriceSplice[1],
),
T(100),
)
numerator := helper.SubtractWithContext(ctx, closingPriceSplice[0], forecast)

return helper.OperateWithContext(ctx, numerator, closingPriceSplice[1], func(num, price T) T {
if price == 0 {
return 0
}

return (num / price) * T(100)
})
}

// IdlePeriod is the initial period that CFO won't yield any results.
Expand Down
21 changes: 21 additions & 0 deletions trend/cfo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,27 @@ func TestCfo(t *testing.T) {
}
}

func TestCfoZeroPrice(t *testing.T) {
cfo := trend.NewCfoWithPeriod[float64](5)

closing := make([]float64, cfo.IdlePeriod()+5)
for i := range closing {
closing[i] = 0
}

actual := helper.ChanToSlice(cfo.Compute(helper.SliceToChan(closing)))

if len(actual) == 0 {
t.Fatal("expected at least one CFO value")
}

for _, v := range actual {
if v != 0 {
t.Fatalf("expected CFO of 0 for a zero closing price, got %v", v)
}
}
}

func TestCfoString(t *testing.T) {
expected := "CFO(14)"
actual := trend.NewCfo[float64]().String()
Expand Down
13 changes: 12 additions & 1 deletion trend/kama.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ const (
// Smoothing Constant (SC) = (ER * (2/(Fast + 1) - 2/(Slow + 1)) + (2/(Slow + 1)))^2
// KAMA = Previous KAMA + SC * (Price - Previous KAMA)
//
// A perfectly flat window (no price change at all) makes Volatility zero,
// which also forces Direction to zero; the Efficiency Ratio is defined as 0
// (no efficient movement occurred) instead of propagating a 0/0 NaN, matching
// most published KAMA implementations' explicit zero-volatility case.
//
// Example:
//
// kama := trend.NewKama[float64]()
Expand Down Expand Up @@ -84,7 +89,13 @@ func (k *Kama[T]) ComputeWithContext(ctx context.Context, closings <-chan T) <-c
)

// Efficiency Ratio (ER) = Direction / Volatility
ers := helper.Divide(directions, volatilitys)
ers := helper.OperateWithContext(ctx, directions, volatilitys, func(direction, volatility T) T {
if volatility == 0 {
return 0
}

return direction / volatility
})

// Smoothing Constant (SC) = (ER * (2/(Fast + 1) - 2/(Slow + 1)) + (2/(Slow + 1)))^2
fastSc := T(2.0) / T(k.FastScPeriod+1)
Expand Down
20 changes: 20 additions & 0 deletions trend/kama_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,26 @@ func TestKamaEmpty(t *testing.T) {
}
}

func TestKamaFlatMarket(t *testing.T) {
closings := make([]float64, trend.DefaultKamaErPeriod+20)
for i := range closings {
closings[i] = 100
}

kama := trend.NewKama[float64]()
actual := helper.ChanToSlice(kama.Compute(helper.SliceToChan(closings)))

if len(actual) == 0 {
t.Fatal("expected at least one KAMA value")
}

for _, v := range actual {
if v != 100 {
t.Fatalf("expected KAMA of 100 for flat market, got %v", v)
}
}
}

func TestKamaCancellation(t *testing.T) {
runtime.GC()
baseline := runtime.NumGoroutine()
Expand Down
19 changes: 14 additions & 5 deletions trend/kdj.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ const (
// D = Sma(K, dPeriod)
// J = (3 * K) - (2 * D)
//
// A zero range (Max(High) == Min(Low)) makes RSV an undefined 0/0. RSV, like
// its Stochastic %K counterpart, is on a 0-100 scale, so it is defined as the
// neutral midpoint 50 instead of propagating NaN.
//
// Example:
//
// kdj := NewKdj[float64]()
Expand Down Expand Up @@ -84,11 +88,16 @@ func (kdj *Kdj[T]) ComputeWithContext(ctx context.Context, high, low, closing <-

closing = helper.SkipWithContext(ctx, closing, kdj.MovingMax.Period-1)

rsv := helper.MultiplyByWithContext(ctx, helper.DivideWithContext(ctx, helper.SubtractWithContext(ctx, closing, lowests[0]),
helper.SubtractWithContext(ctx, highest, lowests[1]),
),
100,
)
numerator := helper.SubtractWithContext(ctx, closing, lowests[0])
denominator := helper.SubtractWithContext(ctx, highest, lowests[1])

rsv := helper.OperateWithContext(ctx, numerator, denominator, func(num, denom T) T {
if denom == 0 {
return 50
}

return (num / denom) * 100
})

ks := helper.DuplicateWithContext(ctx, kdj.Sma1.ComputeWithContext(ctx, rsv),
3,
Expand Down
59 changes: 59 additions & 0 deletions trend/kdj_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package trend_test

import (
"sync"
"testing"

"github.com/cinar/indicator/v2/helper"
Expand Down Expand Up @@ -52,6 +53,64 @@ func TestKdj(t *testing.T) {
}
}

func TestKdjFlatMarket(t *testing.T) {
count := trend.DefaultKdjMinMaxPeriod + trend.DefaultKdjSma1Period + trend.DefaultKdjSma2Period + 5
high := make([]float64, count)
low := make([]float64, count)
closing := make([]float64, count)

for i := range count {
high[i] = 100
low[i] = 100
closing[i] = 100
}

kdj := trend.NewKdj[float64]()
actualK, actualD, actualJ := kdj.Compute(helper.SliceToChan(high), helper.SliceToChan(low), helper.SliceToChan(closing))

// K, D, and J share an upstream fan-out that requires all three output
// channels to be drained concurrently, so they are collected in
// parallel rather than one at a time.
var k, d, j []float64
var wg sync.WaitGroup
wg.Add(3)

go func() {
defer wg.Done()
k = helper.ChanToSlice(actualK)
}()

go func() {
defer wg.Done()
d = helper.ChanToSlice(actualD)
}()

go func() {
defer wg.Done()
j = helper.ChanToSlice(actualJ)
}()

wg.Wait()

if len(k) == 0 {
t.Fatal("expected at least one KDJ value")
}

for i := range k {
if k[i] != 50 {
t.Fatalf("expected K of 50 for a zero-range market, got %v", k[i])
}

if d[i] != 50 {
t.Fatalf("expected D of 50 for a zero-range market, got %v", d[i])
}

if j[i] != 50 {
t.Fatalf("expected J of 50 for a zero-range market, got %v", j[i])
}
}
}

func TestKdjString(t *testing.T) {
expected := "KDJ(9,3,3)"
actual := trend.NewKdj[float64]().String()
Expand Down
19 changes: 14 additions & 5 deletions trend/slow_stochastic.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ const (
// Slow %K = SMA(Fast %K, kPeriod)
// Slow %D = SMA(Slow %K, dPeriod)
//
// Fast %K is a 0-100 range ratio; a zero range (Max == Min) makes it an
// undefined 0/0, defined as the neutral midpoint 50 instead of propagating
// NaN, matching the RSI flat-market convention.
//
// Example:
//
// s := trend.NewSlowStochastic[float64]()
Expand Down Expand Up @@ -80,11 +84,16 @@ func (s *SlowStochastic[T]) ComputeWithContext(ctx context.Context, values <-cha

skipped := helper.SkipWithContext(ctx, inputs[2], movingMin.IdlePeriod())

fastK := helper.MultiplyByWithContext(ctx, helper.DivideWithContext(ctx, helper.SubtractWithContext(ctx, skipped, lowestSplice[0]),
helper.SubtractWithContext(ctx, highest, lowestSplice[1]),
),
100,
)
numerator := helper.SubtractWithContext(ctx, skipped, lowestSplice[0])
denominator := helper.SubtractWithContext(ctx, highest, lowestSplice[1])

fastK := helper.OperateWithContext(ctx, numerator, denominator, func(num, denom T) T {
if denom == 0 {
return 50
}

return (num / denom) * 100
})

slowKSma := NewSmaWithPeriod[T](s.KPeriod)
slowK := slowKSma.ComputeWithContext(ctx, fastK)
Expand Down
45 changes: 45 additions & 0 deletions trend/slow_stochastic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package trend_test

import (
"sync"
"testing"

"github.com/cinar/indicator/v2/helper"
Expand Down Expand Up @@ -48,6 +49,50 @@ func TestSlowStochastic(t *testing.T) {
}
}

func TestSlowStochasticFlatMarket(t *testing.T) {
s := trend.NewSlowStochastic[float64]()

closing := make([]float64, s.IdlePeriod()+5)
for i := range closing {
closing[i] = 100
}

actualK, actualD := s.Compute(helper.SliceToChan(closing))

// %K and %D share an upstream fan-out that requires both output
// channels to be drained concurrently, so they are collected in
// parallel rather than one at a time.
var k, d []float64
var wg sync.WaitGroup
wg.Add(2)

go func() {
defer wg.Done()
k = helper.ChanToSlice(actualK)
}()

go func() {
defer wg.Done()
d = helper.ChanToSlice(actualD)
}()

wg.Wait()

if len(k) == 0 {
t.Fatal("expected at least one Slow Stochastic value")
}

for i := range k {
if k[i] != 50 {
t.Fatalf("expected Slow %%K of 50 for flat market, got %v", k[i])
}

if d[i] != 50 {
t.Fatalf("expected Slow %%D of 50 for flat market, got %v", d[i])
}
}
}

func TestNewSlowStochasticWithPeriod(t *testing.T) {
s := trend.NewSlowStochasticWithPeriod[float64](14, 3, 3)
if s.Period != 14 {
Expand Down
Loading
Loading