Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't document EMA warm-up as unconditional

This new statement is not true for all EMA windows. NewEMAIndicator starts with a 1000-entry cache, and returnIfCached expands and falls through whenever index >= len(cache) before it checks index < windowSize()-1, so a call like NewEMAIndicator(ind, 2000).Calculate(1500) will read the underlying indicator and recurse instead of returning zero even though the first complete window is not available. That can give users wrong guidance, and with a shorter series it can still panic instead of returning the documented zero.

Useful? React with 👍 / 👎.


### Creating trading strategies
```go
indicator := techan.NewClosePriceIndicator(series)
Expand Down
38 changes: 33 additions & 5 deletions analysis.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand All @@ -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++
}
}
Expand All @@ -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)

Expand All @@ -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{
Expand Down
147 changes: 147 additions & 0 deletions analysis_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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))
})
}
4 changes: 2 additions & 2 deletions indicator_exponential_moving_average.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion indicator_simple_moving_average.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
}
Expand Down
18 changes: 4 additions & 14 deletions testutils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading