Skip to content

Commit 9022386

Browse files
cinarclaude
andcommitted
Fix STC dividing by a near-zero denominator, producing -Inf/huge swings
STC = 100*(MACD-%K)/(%D-%K) divided by a denominator that's frequently near zero (%K and %D are both stochastic-smoothed values of the same MACD series and track each other closely), so real-world input could yield -Inf or swings like 1435/-710 for an indicator documented to oscillate 0-100. The standard Schaff Trend Cycle algorithm instead applies the rolling-min/max stochastic normalization *twice* -- once to MACD to get %K1/%D1, then again to %D1 to get %K2/%D2 -- with STC = %D2. Both stages stay bounded in [0, 100] by construction, since they're each a range-normalized percentage smoothed by an SMA, not a division against another stochastic-smoothed value. TestStcFull's fixture was too short for the new, larger warm-up (two stochastic passes need more lead-in than one), so it now generates its own longer synthetic series instead of testdata/stochastic.csv. Fixes #425 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 6653727 commit 9022386

2 files changed

Lines changed: 30 additions & 52 deletions

File tree

trend/stc.go

Lines changed: 20 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,17 @@ const (
3333
// EMA2 = EMA(values, slowPeriod)
3434
// MACD = EMA1 - EMA2
3535
//
36-
// %K = Stochastic %K of MACD with kPeriod
37-
// %D = Stochastic %D of MACD with dPeriod
36+
// %K1, %D1 = Stochastic(MACD, kPeriod, dPeriod)
37+
// %K2, %D2 = Stochastic(%D1, kPeriod, dPeriod)
3838
//
39-
// STC = 100 * (MACD - %K) / (%D - %K)
39+
// STC = %D2
40+
//
41+
// The Stochastic pass (rolling-min/max normalization to a 0-100 range,
42+
// then smoothed by an SMA) is applied twice, once to MACD and again to
43+
// the first pass's %D, the way the standard Schaff Trend Cycle algorithm
44+
// double-smooths MACD -- not once to MACD with a second division against
45+
// its own %K/%D, which isn't bounded to 0-100 and can divide by a
46+
// near-zero denominator.
4047
//
4148
// Example:
4249
//
@@ -96,47 +103,22 @@ func (s *Stc[T]) ComputeWithContext(ctx context.Context, c <-chan T) <-chan T {
96103
c = helper.BufferedWithContext(ctx, c, s.SlowPeriod)
97104
macd := s.Apo.ComputeWithContext(ctx, c)
98105

99-
macd = helper.BufferedWithContext(ctx, macd, s.Stochastic.Period)
100-
inputs := helper.DuplicateWithContext(ctx, macd, 4)
101-
102-
movingMin := NewMovingMinWithPeriod[T](s.Stochastic.Period)
103-
movingMax := NewMovingMaxWithPeriod[T](s.Stochastic.Period)
104-
105-
lowestSplice := helper.DuplicateWithContext(ctx, movingMin.ComputeWithContext(ctx, inputs[0]),
106-
2,
107-
)
106+
// %K1 and %K2 are unused (STC only needs the doubly-smoothed %D), but
107+
// each comes from the same internal duplicate fan-out as %D1/%D2, so
108+
// it still has to be drained -- an unread duplicate branch blocks the
109+
// shared producer, stalling %D1/%D2 too.
110+
k1, d1 := s.Stochastic.ComputeWithContext(ctx, macd)
111+
go helper.DrainWithContext(ctx, k1)
108112

109-
highest := movingMax.ComputeWithContext(ctx, inputs[1])
110-
111-
skipped := helper.SkipWithContext(ctx, inputs[2], movingMin.IdlePeriod())
112-
113-
kValues := helper.MultiplyByWithContext(ctx, helper.DivideWithContext(ctx, helper.SubtractWithContext(ctx, skipped, lowestSplice[0]),
114-
helper.SubtractWithContext(ctx, highest, lowestSplice[1]),
115-
),
116-
100,
117-
)
113+
k2, d2 := s.Stochastic.ComputeWithContext(ctx, d1)
114+
go helper.DrainWithContext(ctx, k2)
118115

119-
kDuplicate := helper.DuplicateWithContext(ctx, kValues, 2)
120-
121-
d := s.Stochastic.Sma.ComputeWithContext(ctx, kDuplicate[0])
122-
123-
kForStcSplice := helper.DuplicateWithContext(ctx,
124-
helper.SkipWithContext(ctx, kDuplicate[1], s.Stochastic.Sma.IdlePeriod()),
125-
2,
126-
)
127-
128-
macdForStc := helper.SkipWithContext(ctx, inputs[3], s.Stochastic.IdlePeriod())
129-
130-
return helper.MultiplyByWithContext(ctx, helper.DivideWithContext(ctx, helper.SubtractWithContext(ctx, macdForStc, kForStcSplice[0]),
131-
helper.SubtractWithContext(ctx, d, kForStcSplice[1]),
132-
),
133-
100,
134-
)
116+
return d2
135117
}
136118

137119
// IdlePeriod is the initial period that STC won't yield any results.
138120
func (s *Stc[T]) IdlePeriod() int {
139-
return s.Apo.IdlePeriod() + s.Stochastic.IdlePeriod()
121+
return s.Apo.IdlePeriod() + 2*s.Stochastic.IdlePeriod()
140122
}
141123

142124
// Compute wraps ComputeWithContext for backwards compatibility.

trend/stc_test.go

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,27 +51,23 @@ func TestStcSlowStochastic(t *testing.T) {
5151
}
5252
}
5353

54+
// TestStcFull checks that STC's output length matches IdlePeriod() once the
55+
// Stochastic pass is applied twice (once to MACD, again to its %D), which
56+
// needs more warm-up than the 19-row testdata/stochastic.csv fixture
57+
// (shared with TestStcSlowStochastic/TestStochastic) has, so this generates
58+
// its own longer synthetic series instead.
5459
func TestStcFull(t *testing.T) {
55-
type Data struct {
56-
Value float64
57-
K float64
58-
D float64
60+
values := make([]float64, 200)
61+
for i := range values {
62+
values[i] = 100 + float64(i%7)
5963
}
6064

61-
input, err := helper.ReadFromCsvFile[Data]("testdata/stochastic.csv")
62-
if err != nil {
63-
t.Fatal(err)
64-
}
65-
66-
inputSlice := helper.ChanToSlice(input)
67-
values := helper.Map(helper.SliceToChan(inputSlice), func(d *Data) float64 { return d.Value })
68-
6965
stc := trend.NewStcWithPeriod[float64](5, 10, 5, 3)
70-
result := stc.Compute(values)
66+
result := stc.Compute(helper.SliceToChan(values))
7167

7268
slice := helper.ChanToSlice(result)
7369

74-
expected := len(inputSlice) - stc.IdlePeriod()
70+
expected := len(values) - stc.IdlePeriod()
7571
if len(slice) != expected {
7672
t.Fatalf("expected %d values, got %d", expected, len(slice))
7773
}

0 commit comments

Comments
 (0)