From 3cc03f446a1a524fcb1108a52cdb625fe303a311 Mon Sep 17 00:00:00 2001 From: sdcoffey Date: Fri, 29 May 2026 08:23:46 -0700 Subject: [PATCH 1/6] Add indicator cache invalidation --- cached_indicator.go | 28 ++++++++++++++++++++ indicator_exponential_moving_average.go | 5 ++++ indicator_exponential_moving_average_test.go | 21 +++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/cached_indicator.go b/cached_indicator.go index 4bd8f97..51c633e 100644 --- a/cached_indicator.go +++ b/cached_indicator.go @@ -11,6 +11,24 @@ type cachedIndicator interface { windowSize() int } +// CacheResetter describes indicators whose cached values can be invalidated after +// their underlying data changes. +type CacheResetter interface { + ResetCacheFrom(index int) +} + +// ResetCacheFrom invalidates cached values from index onward when the indicator +// supports cache resets. It returns true when a cache was reset. +func ResetCacheFrom(indicator Indicator, index int) bool { + resetter, ok := indicator.(CacheResetter) + if !ok { + return false + } + + resetter.ResetCacheFrom(index) + return true +} + func cacheResult(indicator cachedIndicator, index int, val big.Decimal) { if index < len(indicator.cache()) { indicator.cache()[index] = &val @@ -29,6 +47,16 @@ func expandResultCache(indicator cachedIndicator, newSize int) { indicator.setCache(append(indicator.cache(), expansion...)) } +func resetResultCache(indicator cachedIndicator, index int) { + if index < 0 { + index = 0 + } + + for i := index; i < len(indicator.cache()); i++ { + indicator.cache()[i] = nil + } +} + func returnIfCached(indicator cachedIndicator, index int, firstValueFallback func(int) big.Decimal) *big.Decimal { if index >= len(indicator.cache()) { expandResultCache(indicator, index+1) diff --git a/indicator_exponential_moving_average.go b/indicator_exponential_moving_average.go index dfdb86f..fe7c85e 100644 --- a/indicator_exponential_moving_average.go +++ b/indicator_exponential_moving_average.go @@ -43,3 +43,8 @@ func (ema *emaIndicator) setCache(newCache resultCache) { } func (ema emaIndicator) windowSize() int { return ema.window } + +// ResetCacheFrom invalidates cached EMA values from index onward. +func (ema *emaIndicator) ResetCacheFrom(index int) { + resetResultCache(ema, index) +} diff --git a/indicator_exponential_moving_average_test.go b/indicator_exponential_moving_average_test.go index 142c5b6..7a05d75 100644 --- a/indicator_exponential_moving_average_test.go +++ b/indicator_exponential_moving_average_test.go @@ -3,6 +3,7 @@ package techan import ( "testing" + "github.com/sdcoffey/big" "github.com/stretchr/testify/assert" ) @@ -37,6 +38,26 @@ func TestExponentialMovingAverage(t *testing.T) { assert.True(t, ok) assert.EqualValues(t, 1001, len(emaStruct.cache())) }) + + t.Run("Can reset cached values after the underlying series changes", func(t *testing.T) { + series := mockTimeSeriesFl(10, 10, 10, 10) + ema := NewEMAIndicator(NewClosePriceIndicator(series), 3) + + decimalEquals(t, 10, ema.Calculate(3)) + + series.Candles[3].ClosePrice = big.NewFromString("20") + decimalEquals(t, 10, ema.Calculate(3)) + + assert.True(t, ResetCacheFrom(ema, 3)) + decimalEquals(t, 15, ema.Calculate(3)) + }) + + t.Run("Reports when an indicator does not support cache resets", func(t *testing.T) { + series := mockTimeSeriesFl(10, 20, 30) + sma := NewSimpleMovingAverage(NewClosePriceIndicator(series), 3) + + assert.False(t, ResetCacheFrom(sma, 0)) + }) } func BenchmarkExponetialMovingAverage(b *testing.B) { From 31f89d0773467555e62d2d5bb8043c38fe5045d0 Mon Sep 17 00:00:00 2001 From: sdcoffey Date: Fri, 29 May 2026 08:41:11 -0700 Subject: [PATCH 2/6] Reset all built-in cached indicators --- cached_indicator.go | 17 +++++++++++------ indicator_modified_moving_average_test.go | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/cached_indicator.go b/cached_indicator.go index 51c633e..f5ece21 100644 --- a/cached_indicator.go +++ b/cached_indicator.go @@ -18,15 +18,20 @@ type CacheResetter interface { } // ResetCacheFrom invalidates cached values from index onward when the indicator -// supports cache resets. It returns true when a cache was reset. +// supports cache resets or uses the built-in result cache. It returns true when +// a cache was reset. func ResetCacheFrom(indicator Indicator, index int) bool { - resetter, ok := indicator.(CacheResetter) - if !ok { - return false + if resetter, ok := indicator.(CacheResetter); ok { + resetter.ResetCacheFrom(index) + return true } - resetter.ResetCacheFrom(index) - return true + if cached, ok := indicator.(cachedIndicator); ok { + resetResultCache(cached, index) + return true + } + + return false } func cacheResult(indicator cachedIndicator, index int, val big.Decimal) { diff --git a/indicator_modified_moving_average_test.go b/indicator_modified_moving_average_test.go index 5e20a9e..0cec6cd 100644 --- a/indicator_modified_moving_average_test.go +++ b/indicator_modified_moving_average_test.go @@ -2,6 +2,9 @@ package techan import ( "testing" + + "github.com/sdcoffey/big" + "github.com/stretchr/testify/assert" ) func TestModifiedMovingAverage(t *testing.T) { @@ -24,3 +27,16 @@ func TestModifiedMovingAverage(t *testing.T) { indicatorEquals(t, expected, indicator) } + +func TestModifiedMovingAverage_ResetCacheFrom(t *testing.T) { + series := mockTimeSeriesFl(10, 10, 10, 10) + mma := NewMMAIndicator(NewClosePriceIndicator(series), 3) + + decimalEquals(t, 10, mma.Calculate(3)) + + series.Candles[3].ClosePrice = big.NewFromString("20") + decimalEquals(t, 10, mma.Calculate(3)) + + assert.True(t, ResetCacheFrom(mma, 3)) + decimalEquals(t, 13.3333, mma.Calculate(3)) +} From 21d51741c79e5bc74e1eb9d02765f8c1a95f7d85 Mon Sep 17 00:00:00 2001 From: sdcoffey Date: Fri, 29 May 2026 08:42:01 -0700 Subject: [PATCH 3/6] Honor warm-up before cache expansion --- cached_indicator.go | 10 +++++++--- indicator_exponential_moving_average_test.go | 7 +++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/cached_indicator.go b/cached_indicator.go index f5ece21..239bbc4 100644 --- a/cached_indicator.go +++ b/cached_indicator.go @@ -63,11 +63,15 @@ func resetResultCache(indicator cachedIndicator, index int) { } func returnIfCached(indicator cachedIndicator, index int, firstValueFallback func(int) big.Decimal) *big.Decimal { + if index < indicator.windowSize()-1 { + return &big.ZERO + } + if index >= len(indicator.cache()) { expandResultCache(indicator, index+1) - } else if index < indicator.windowSize()-1 { - return &big.ZERO - } else if val := indicator.cache()[index]; val != nil { + } + + if val := indicator.cache()[index]; val != nil { return val } else if index == indicator.windowSize()-1 { value := firstValueFallback(index) diff --git a/indicator_exponential_moving_average_test.go b/indicator_exponential_moving_average_test.go index 7a05d75..200257a 100644 --- a/indicator_exponential_moving_average_test.go +++ b/indicator_exponential_moving_average_test.go @@ -52,6 +52,13 @@ func TestExponentialMovingAverage(t *testing.T) { decimalEquals(t, 15, ema.Calculate(3)) }) + t.Run("Returns zero before large windows have enough values", func(t *testing.T) { + series := mockTimeSeriesFl(10) + ema := NewEMAIndicator(NewClosePriceIndicator(series), 2000) + + assert.EqualValues(t, "0", ema.Calculate(1500).String()) + }) + t.Run("Reports when an indicator does not support cache resets", func(t *testing.T) { series := mockTimeSeriesFl(10, 20, 30) sma := NewSimpleMovingAverage(NewClosePriceIndicator(series), 3) From 9839f7f8cdec5c53af79a59c72f65e959d9583cb Mon Sep 17 00:00:00 2001 From: sdcoffey Date: Fri, 29 May 2026 08:24:52 -0700 Subject: [PATCH 4/6] Document indicator warm-up behavior --- README.md | 6 +++++- indicator_exponential_moving_average.go | 4 ++-- indicator_simple_moving_average.go | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4368cff..577dcf4 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,13 @@ for _, datum := range dataset { closePrices := techan.NewClosePriceIndicator(series) movingAverage := techan.NewEMAIndicator(closePrices, 10) // Create an exponential moving average with a window of 10 -fmt.Println(movingAverage.Calculate(0).FormattedString(2)) +if series.LastIndex() >= 9 { + fmt.Println(movingAverage.Calculate(series.LastIndex()).FormattedString(2)) +} ``` +Windowed indicators such as SMA and EMA return `0` until enough data exists to fill their first window. For example, a 10-period EMA starts producing calculated values at index `9`. + ### Creating trading strategies ```go indicator := techan.NewClosePriceIndicator(series) diff --git a/indicator_exponential_moving_average.go b/indicator_exponential_moving_average.go index fe7c85e..bd5e7ab 100644 --- a/indicator_exponential_moving_average.go +++ b/indicator_exponential_moving_average.go @@ -10,8 +10,8 @@ type emaIndicator struct { } // NewEMAIndicator returns a derivative indicator which returns the average of the current and preceding values in -// the given windowSize, with values closer to current index given more weight. A more in-depth explanation can be found here: -// http://www.investopedia.com/terms/e/ema.asp +// the given windowSize, with values closer to current index given more weight. It returns zero for indices before +// the first complete window. A more in-depth explanation can be found here: http://www.investopedia.com/terms/e/ema.asp func NewEMAIndicator(indicator Indicator, window int) Indicator { return &emaIndicator{ indicator: indicator, diff --git a/indicator_simple_moving_average.go b/indicator_simple_moving_average.go index 805f4dd..8ba4b8a 100644 --- a/indicator_simple_moving_average.go +++ b/indicator_simple_moving_average.go @@ -8,7 +8,7 @@ type smaIndicator struct { } // NewSimpleMovingAverage returns a derivative Indicator which returns the average of the current value and preceding -// values in the given windowSize. +// values in the given windowSize. It returns zero for indices before the first complete window. func NewSimpleMovingAverage(indicator Indicator, window int) Indicator { return smaIndicator{indicator, window} } From 8a3d4147f6b24d66570ebb800fcc2167fd607065 Mon Sep 17 00:00:00 2001 From: sdcoffey Date: Fri, 29 May 2026 08:26:17 -0700 Subject: [PATCH 5/6] Harden analysis edge cases --- analysis.go | 38 ++++++++++-- analysis_test.go | 147 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 5 deletions(-) diff --git a/analysis.go b/analysis.go index 9a2a3da..6b5a0db 100644 --- a/analysis.go +++ b/analysis.go @@ -46,7 +46,12 @@ type PercentGainAnalysis struct{} // Analyze analyzes the trading record for the percentage profit gained relative to start func (pga PercentGainAnalysis) Analyze(record *TradingRecord) float64 { if len(record.Trades) > 0 && record.Trades[0].IsClosed() { - return (record.Trades[len(record.Trades)-1].ExitValue().Div(record.Trades[0].CostBasis())).Sub(big.NewDecimal(1)).Float() + costBasis := record.Trades[0].CostBasis() + if costBasis.EQ(big.ZERO) { + return 0 + } + + return (record.Trades[len(record.Trades)-1].ExitValue().Div(costBasis)).Sub(big.NewDecimal(1)).Float() } return 0 @@ -92,10 +97,18 @@ type PeriodProfitAnalysis struct { // Analyze returns the average profit for the trading record based on the given duration func (ppa PeriodProfitAnalysis) Analyze(record *TradingRecord) float64 { + if len(record.Trades) == 0 || ppa.Period <= 0 { + return 0 + } + var tp TotalProfitAnalysis totalProfit := tp.Analyze(record) periods := record.Trades[len(record.Trades)-1].ExitOrder().ExecutionTime.Sub(record.Trades[0].EntranceOrder().ExecutionTime) / ppa.Period + if periods == 0 { + return 0 + } + return totalProfit / float64(periods) } @@ -106,10 +119,16 @@ type ProfitableTradesAnalysis struct{} func (pta ProfitableTradesAnalysis) Analyze(record *TradingRecord) float64 { var profitableTrades int for _, trade := range record.Trades { + if !trade.IsClosed() { + continue + } + costBasis := trade.EntranceOrder().Amount.Mul(trade.EntranceOrder().Price) sellPrice := trade.ExitOrder().Amount.Mul(trade.ExitOrder().Price) - if sellPrice.GT(costBasis) { + if trade.IsLong() && sellPrice.GT(costBasis) { + profitableTrades++ + } else if trade.IsShort() && sellPrice.LT(costBasis) { profitableTrades++ } } @@ -123,6 +142,10 @@ type AverageProfitAnalysis struct{} // Analyze returns the average profit of the trading record func (apa AverageProfitAnalysis) Analyze(record *TradingRecord) float64 { + if len(record.Trades) == 0 { + return 0 + } + var tp TotalProfitAnalysis totalProft := tp.Analyze(record) @@ -139,14 +162,19 @@ type BuyAndHoldAnalysis struct { // Analyze returns the profit based on a simple buy and hold strategy func (baha BuyAndHoldAnalysis) Analyze(record *TradingRecord) float64 { - if len(record.Trades) == 0 { + if len(record.Trades) == 0 || baha.TimeSeries == nil || len(baha.TimeSeries.Candles) == 0 { + return 0 + } + + firstClose := baha.TimeSeries.Candles[0].ClosePrice + if firstClose.EQ(big.ZERO) { return 0 } openOrder := Order{ Side: BUY, - Amount: big.NewDecimal(baha.StartingMoney).Div(baha.TimeSeries.Candles[0].ClosePrice), - Price: baha.TimeSeries.Candles[0].ClosePrice, + Amount: big.NewDecimal(baha.StartingMoney).Div(firstClose), + Price: firstClose, } closeOrder := Order{ diff --git a/analysis_test.go b/analysis_test.go index 8fafa45..45e7227 100644 --- a/analysis_test.go +++ b/analysis_test.go @@ -190,6 +190,28 @@ func TestPercentGainAnalysis(t *testing.T) { gain := pga.Analyze(record) assert.EqualValues(t, -.375, gain) }) + + t.Run("Zero cost basis", func(t *testing.T) { + record := NewTradingRecord() + + record.Operate(Order{ + Side: BUY, + Amount: big.NewDecimal(1), + Price: big.ZERO, + Security: example, + ExecutionTime: time.Now(), + }) + record.Operate(Order{ + Side: SELL, + Amount: big.NewDecimal(1), + Price: big.NewDecimal(1), + Security: example, + ExecutionTime: time.Now(), + }) + + pga := PercentGainAnalysis{} + assert.EqualValues(t, 0, pga.Analyze(record)) + }) } func TestNumTradesAnalysis(t *testing.T) { @@ -283,6 +305,59 @@ func TestLogTradesAnalysis(t *testing.T) { } func TestPeriodProfitAnalysis(t *testing.T) { + t.Run("Zero when there are no trades", func(t *testing.T) { + ppa := PeriodProfitAnalysis{ + Period: time.Minute, + } + + assert.EqualValues(t, 0, ppa.Analyze(NewTradingRecord())) + }) + + t.Run("Zero when period is invalid", func(t *testing.T) { + record := NewTradingRecord() + record.Operate(Order{ + Side: BUY, + Amount: big.NewDecimal(1), + Price: big.NewDecimal(1), + Security: example, + ExecutionTime: time.Now(), + }) + record.Operate(Order{ + Side: SELL, + Amount: big.NewDecimal(2), + Price: big.NewDecimal(1), + Security: example, + ExecutionTime: time.Now(), + }) + + assert.EqualValues(t, 0, PeriodProfitAnalysis{}.Analyze(record)) + }) + + t.Run("Zero when no full periods have elapsed", func(t *testing.T) { + record := NewTradingRecord() + now := time.Now() + record.Operate(Order{ + Side: BUY, + Amount: big.NewDecimal(1), + Price: big.NewDecimal(1), + Security: example, + ExecutionTime: now, + }) + record.Operate(Order{ + Side: SELL, + Amount: big.NewDecimal(2), + Price: big.NewDecimal(1), + Security: example, + ExecutionTime: now.Add(time.Minute), + }) + + ppa := PeriodProfitAnalysis{ + Period: time.Hour, + } + + assert.EqualValues(t, 0, ppa.Analyze(record)) + }) + record := NewTradingRecord() now := time.Now().Add(-time.Minute * 5) @@ -370,9 +445,56 @@ func TestProfitableTradesAnalysis(t *testing.T) { pta := ProfitableTradesAnalysis{} assert.EqualValues(t, 1, pta.Analyze(record)) + + t.Run("Counts profitable short trades", func(t *testing.T) { + record := NewTradingRecord() + + orders := []Order{ + { + Side: SELL, + Amount: big.NewDecimal(1), + Price: big.NewDecimal(10), + Security: example, + ExecutionTime: time.Now(), + }, + { + Side: BUY, + Amount: big.NewDecimal(1), + Price: big.NewDecimal(8), + Security: example, + ExecutionTime: time.Now(), + }, + { + Side: SELL, + Amount: big.NewDecimal(1), + Price: big.NewDecimal(10), + Security: example, + ExecutionTime: time.Now(), + }, + { + Side: BUY, + Amount: big.NewDecimal(1), + Price: big.NewDecimal(12), + Security: example, + ExecutionTime: time.Now(), + }, + } + + for _, order := range orders { + record.Operate(order) + } + + assert.EqualValues(t, 1, pta.Analyze(record)) + }) } func TestAverageProfitAnalysis(t *testing.T) { + t.Run("Zero when there are no trades", func(t *testing.T) { + pta := AverageProfitAnalysis{} + + assert.EqualValues(t, 0, pta.Analyze(NewTradingRecord())) + }) + record := NewTradingRecord() orders := []Order{ @@ -471,4 +593,29 @@ func TestBuyAndHoldAnalysis(t *testing.T) { assert.EqualValues(t, 5, buyAndHoldAnalysis.Analyze(record)) }) + + t.Run("== 0 candles returns zero", func(t *testing.T) { + record := NewTradingRecord() + record.Operate(Order{ + Side: BUY, + Amount: big.NewDecimal(1), + Price: big.NewDecimal(1), + Security: example, + ExecutionTime: time.Now(), + }) + record.Operate(Order{ + Side: SELL, + Amount: big.NewDecimal(2), + Price: big.NewDecimal(1), + Security: example, + ExecutionTime: time.Now(), + }) + + buyAndHoldAnalysis := BuyAndHoldAnalysis{ + TimeSeries: NewTimeSeries(), + StartingMoney: 1, + } + + assert.EqualValues(t, 0, buyAndHoldAnalysis.Analyze(record)) + }) } From 3834d49c9f3f486f0071d52ee0a21b9251487805 Mon Sep 17 00:00:00 2001 From: sdcoffey Date: Fri, 29 May 2026 08:27:17 -0700 Subject: [PATCH 6/6] Make indicator test helper vet-safe --- testutils.go | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/testutils.go b/testutils.go index dd0303c..c5479ba 100644 --- a/testutils.go +++ b/testutils.go @@ -88,24 +88,14 @@ func decimalEquals(t *testing.T, expected float64, actual big.Decimal) { assert.Equal(t, fmt.Sprintf("%.4f", expected), fmt.Sprintf("%.4f", actual.Float())) } -func dump(indicator Indicator) (values []float64) { +func indicatorEquals(t *testing.T, expected []float64, indicator Indicator) { precision := 4.0 m := math.Pow(10, precision) - defer func() { - recover() - }() - - var index int - for { - values = append(values, math.Round(indicator.Calculate(index).Float()*m)/m) - index++ + actualValues := make([]float64, len(expected)) + for i := range expected { + actualValues[i] = math.Round(indicator.Calculate(i).Float()*m) / m } - return -} - -func indicatorEquals(t *testing.T, expected []float64, indicator Indicator) { - actualValues := dump(indicator) assert.EqualValues(t, expected, actualValues) }