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
39 changes: 39 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Test

on:
pull_request:
push:
branches:
- main

jobs:
test:
name: Go ${{ matrix.go-version }}
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
go-version:
- 1.21.x
- 1.22.x
- 1.23.x
- 1.24.x
- 1.25.x
- 1.26.x

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
cache: true

- name: Run tests
run: go test ./...

- name: Run race and coverage tests
run: go test -race -cover -covermode=atomic -coverprofile=coverage.txt github.com/sdcoffey/techan
13 changes: 0 additions & 13 deletions .travis.yml

This file was deleted.

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
Loading
Loading