Skip to content
Merged
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`.

### 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))
})
}
43 changes: 40 additions & 3 deletions cached_indicator.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,29 @@ 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 or uses the built-in result cache. It returns true when
// a cache was reset.
func ResetCacheFrom(indicator Indicator, index int) bool {
if resetter, ok := indicator.(CacheResetter); ok {
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) {
if index < len(indicator.cache()) {
indicator.cache()[index] = &val
Expand All @@ -29,12 +52,26 @@ 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 < 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)
Expand Down
9 changes: 7 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 Expand Up @@ -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)
}
28 changes: 28 additions & 0 deletions indicator_exponential_moving_average_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package techan
import (
"testing"

"github.com/sdcoffey/big"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -37,6 +38,33 @@ 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("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)

assert.False(t, ResetCacheFrom(sma, 0))
})
}

func BenchmarkExponetialMovingAverage(b *testing.B) {
Expand Down
Loading
Loading