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/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)) + }) } 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} } 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) }