From 8a3d4147f6b24d66570ebb800fcc2167fd607065 Mon Sep 17 00:00:00 2001 From: sdcoffey Date: Fri, 29 May 2026 08:26:17 -0700 Subject: [PATCH 1/2] 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 2/2] 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) }