From 3f1f939aec7ffa337f031d70f1c363776b813b25 Mon Sep 17 00:00:00 2001 From: Onur Cinar Date: Thu, 3 Sep 2026 02:20:10 +0000 Subject: [PATCH] Add zero-denominator guards to Bop/Kama/Kdj/Tsi/Vwma/Cfo/SlowStochastic/Stochastic Fixes NaN/Inf propagation when these trend indicators divide by a quantity that can legitimately be zero on real market data (a flat bar's high==low, a perfectly flat lookback window, an untraded window's zero volume). Each fallback follows the indicator's own established convention rather than an arbitrary value: 0-100 range oscillators (Kdj's RSV/K/D/J, SlowStochastic, Stochastic) fall back to the neutral midpoint 50, matching the existing RSI flat-market fix; signed/zero-centered indicators (Bop, Kama's Efficiency Ratio, Tsi, Cfo) fall back to 0; Vwma falls back to 0 with a documented limitation around forward-filling. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Xv4stuAb6WuQ8rPZ4cupLp --- trend/bop.go | 15 ++++++++- trend/bop_test.go | 20 ++++++++++++ trend/cfo.go | 20 +++++++----- trend/cfo_test.go | 21 +++++++++++++ trend/kama.go | 13 +++++++- trend/kama_test.go | 20 ++++++++++++ trend/kdj.go | 19 ++++++++--- trend/kdj_test.go | 59 +++++++++++++++++++++++++++++++++++ trend/slow_stochastic.go | 19 ++++++++--- trend/slow_stochastic_test.go | 45 ++++++++++++++++++++++++++ trend/stochastic.go | 23 +++++++++----- trend/stochastic_test.go | 45 ++++++++++++++++++++++++++ trend/tsi.go | 19 ++++++----- trend/tsi_test.go | 21 +++++++++++++ trend/vwma.go | 20 ++++++++++-- trend/vwma_test.go | 25 +++++++++++++++ 16 files changed, 368 insertions(+), 36 deletions(-) diff --git a/trend/bop.go b/trend/bop.go index 06f5a99..f4ec05f 100644 --- a/trend/bop.go +++ b/trend/bop.go @@ -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 @@ -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. diff --git a/trend/bop_test.go b/trend/bop_test.go index d3801b1..801b7f2 100644 --- a/trend/bop_test.go +++ b/trend/bop_test.go @@ -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() diff --git a/trend/cfo.go b/trend/cfo.go index 1a2e4cb..f6d3300 100644 --- a/trend/cfo.go +++ b/trend/cfo.go @@ -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]() @@ -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. diff --git a/trend/cfo_test.go b/trend/cfo_test.go index df0c1cc..705003f 100644 --- a/trend/cfo_test.go +++ b/trend/cfo_test.go @@ -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() diff --git a/trend/kama.go b/trend/kama.go index f79e0fe..84c8459 100644 --- a/trend/kama.go +++ b/trend/kama.go @@ -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]() @@ -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) diff --git a/trend/kama_test.go b/trend/kama_test.go index 0156ecc..8d4863a 100644 --- a/trend/kama_test.go +++ b/trend/kama_test.go @@ -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() diff --git a/trend/kdj.go b/trend/kdj.go index b3ffd8b..fc36840 100644 --- a/trend/kdj.go +++ b/trend/kdj.go @@ -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]() @@ -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, diff --git a/trend/kdj_test.go b/trend/kdj_test.go index ceba4ae..3639e21 100644 --- a/trend/kdj_test.go +++ b/trend/kdj_test.go @@ -5,6 +5,7 @@ package trend_test import ( + "sync" "testing" "github.com/cinar/indicator/v2/helper" @@ -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() diff --git a/trend/slow_stochastic.go b/trend/slow_stochastic.go index 1ceba39..84ce893 100644 --- a/trend/slow_stochastic.go +++ b/trend/slow_stochastic.go @@ -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]() @@ -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) diff --git a/trend/slow_stochastic_test.go b/trend/slow_stochastic_test.go index 06fbde5..557c7df 100644 --- a/trend/slow_stochastic_test.go +++ b/trend/slow_stochastic_test.go @@ -5,6 +5,7 @@ package trend_test import ( + "sync" "testing" "github.com/cinar/indicator/v2/helper" @@ -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 { diff --git a/trend/stochastic.go b/trend/stochastic.go index 958274f..dff7847 100644 --- a/trend/stochastic.go +++ b/trend/stochastic.go @@ -28,6 +28,10 @@ const ( // K = (Value - Min(Value, period)) / (Max(Value, period) - Min(Value, period)) * 100 // D = SMA(K, dPeriod) // +// %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.NewStochastic[float64]() @@ -70,13 +74,18 @@ func (s *Stochastic[T]) ComputeWithContext(ctx context.Context, values <-chan T) skipped := helper.SkipWithContext(ctx, inputs[2], movingMin.IdlePeriod()) - kSplice := helper.DuplicateWithContext(ctx, helper.MultiplyByWithContext(ctx, helper.DivideWithContext(ctx, helper.SubtractWithContext(ctx, skipped, lowestSplice[0]), - helper.SubtractWithContext(ctx, highest, lowestSplice[1]), - ), - 100, - ), - 2, - ) + numerator := helper.SubtractWithContext(ctx, skipped, lowestSplice[0]) + denominator := helper.SubtractWithContext(ctx, highest, lowestSplice[1]) + + k := helper.OperateWithContext(ctx, numerator, denominator, func(num, denom T) T { + if denom == 0 { + return 50 + } + + return (num / denom) * 100 + }) + + kSplice := helper.DuplicateWithContext(ctx, k, 2) d := s.Sma.ComputeWithContext(ctx, kSplice[0]) kSplice[1] = helper.SkipWithContext(ctx, kSplice[1], s.Sma.IdlePeriod()) diff --git a/trend/stochastic_test.go b/trend/stochastic_test.go index dca77bf..e783e9b 100644 --- a/trend/stochastic_test.go +++ b/trend/stochastic_test.go @@ -5,6 +5,7 @@ package trend_test import ( + "sync" "testing" "github.com/cinar/indicator/v2/helper" @@ -42,6 +43,50 @@ func TestStochastic(t *testing.T) { } } +func TestStochasticFlatMarket(t *testing.T) { + s := trend.NewStochastic[float64]() + + values := make([]float64, s.IdlePeriod()+5) + for i := range values { + values[i] = 100 + } + + actualK, actualD := s.Compute(helper.SliceToChan(values)) + + // %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 Stochastic value") + } + + for i := range k { + if k[i] != 50 { + t.Fatalf("expected %%K of 50 for flat market, got %v", k[i]) + } + + if d[i] != 50 { + t.Fatalf("expected %%D of 50 for flat market, got %v", d[i]) + } + } +} + func TestStochasticString(t *testing.T) { expected := "STOCHASTIC(10,3)" actual := trend.NewStochastic[float64]().String() diff --git a/trend/tsi.go b/trend/tsi.go index 9f25971..46bfc9e 100644 --- a/trend/tsi.go +++ b/trend/tsi.go @@ -27,6 +27,11 @@ const ( // APCDS = Ema(13, Ema(25, Abs(Current - Prior))) // TSI = (PCDS / APCDS) * 100 // +// APCDS (the smoothed absolute price change) is zero only when price has +// been perfectly flat, in which case there is no momentum to report; TSI is +// defined as 0, its neutral center on the signed -100 to 100 scale, instead +// of propagating a 0/0 NaN. +// // Example: // // tsi := trend.NewTsi[float64]() @@ -69,13 +74,13 @@ func (t *Tsi[T]) ComputeWithContext(ctx context.Context, closings <-chan T) <-ch apcds := ComputeMaWithContext(ctx, t.SecondSmoothing, ComputeMaWithContext(ctx, t.FirstSmoothing, helper.AbsWithContext(ctx, pcsSplice[1]))) // TSI = (PCDS / APCDS) * 100 - tsi := helper.MultiplyByWithContext(ctx, - helper.DivideWithContext(ctx, - pcds, - apcds, - ), - T(100), - ) + tsi := helper.OperateWithContext(ctx, pcds, apcds, func(pcd, apcd T) T { + if apcd == 0 { + return 0 + } + + return (pcd / apcd) * T(100) + }) return tsi } diff --git a/trend/tsi_test.go b/trend/tsi_test.go index 7d065e3..2d609c1 100644 --- a/trend/tsi_test.go +++ b/trend/tsi_test.go @@ -38,6 +38,27 @@ func TestTsi(t *testing.T) { } } +func TestTsiFlatMarket(t *testing.T) { + tsi := trend.NewTsi[float64]() + + closings := make([]float64, tsi.IdlePeriod()+20) + for i := range closings { + closings[i] = 100 + } + + actual := helper.ChanToSlice(tsi.Compute(helper.SliceToChan(closings))) + + if len(actual) == 0 { + t.Fatal("expected at least one TSI value") + } + + for _, v := range actual { + if v != 0 { + t.Fatalf("expected TSI of 0 for flat market, got %v", v) + } + } +} + func TestTsiString(t *testing.T) { expected := "TSI(EMA(1),EMA(2))" actual := trend.NewTsiWith[float64](1, 2).String() diff --git a/trend/vwma.go b/trend/vwma.go index 28bd80c..dddbd8e 100644 --- a/trend/vwma.go +++ b/trend/vwma.go @@ -21,6 +21,13 @@ const ( // greater weight. // // VWMA = Sum(Price * Volume) / Sum(Volume) +// +// A window with no trading at all makes Sum(Volume) zero, so there is no +// real volume-weighted price to report; VWMA is defined as 0 rather than +// propagating a 0/0 NaN. This is a known limitation: forward-filling the +// last valid average would be more representative for plotting purposes, +// but would require per-window state that no other guard in this indicator +// family needs, so it is left as a documented edge case instead. type Vwma[T helper.Float] struct { // Time period. Period int @@ -40,9 +47,16 @@ func (v *Vwma[T]) ComputeWithContext(ctx context.Context, closing, volume <-chan sum := NewMovingSum[T]() sum.Period = v.Period - return helper.DivideWithContext(ctx, sum.ComputeWithContext(ctx, helper.MultiplyWithContext(ctx, closing, volumes[0])), - sum.ComputeWithContext(ctx, volumes[1]), - ) + priceVolumeSum := sum.ComputeWithContext(ctx, helper.MultiplyWithContext(ctx, closing, volumes[0])) + volumeSum := sum.ComputeWithContext(ctx, volumes[1]) + + return helper.OperateWithContext(ctx, priceVolumeSum, volumeSum, func(pv, vol T) T { + if vol == 0 { + return 0 + } + + return pv / vol + }) } // IdlePeriod is the initial period that VWMA won't yield any results. diff --git a/trend/vwma_test.go b/trend/vwma_test.go index 4cf0682..82c758f 100644 --- a/trend/vwma_test.go +++ b/trend/vwma_test.go @@ -41,6 +41,31 @@ func TestVwma(t *testing.T) { } } +func TestVwmaNoVolume(t *testing.T) { + vwma := trend.NewVwma[float64]() + + count := vwma.Period + 5 + closing := make([]float64, count) + volume := make([]float64, count) + + for i := range count { + closing[i] = 100 + volume[i] = 0 + } + + actual := helper.ChanToSlice(vwma.Compute(helper.SliceToChan(closing), helper.SliceToChan(volume))) + + if len(actual) == 0 { + t.Fatal("expected at least one VWMA value") + } + + for _, v := range actual { + if v != 0 { + t.Fatalf("expected VWMA of 0 for a window with no volume, got %v", v) + } + } +} + func TestVwmaString(t *testing.T) { expected := "VWMA(20)" actual := trend.NewVwma[float64]().String()