Skip to content

Commit

Permalink
add more portfolio return metrics calculations
Browse files Browse the repository at this point in the history
  • Loading branch information
crhntr committed Sep 2, 2024
1 parent 52295dc commit 808d2aa
Show file tree
Hide file tree
Showing 3 changed files with 10,231 additions and 0 deletions.
110 changes: 110 additions & 0 deletions calculate/metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package calculate

import (
"math"

"gonum.org/v1/gonum/floats"
"gonum.org/v1/gonum/stat"
"gonum.org/v1/gonum/stat/distuv"
)

func DownsideVolatility(values []float64, periodsPerYear float64) float64 {
negative := make([]float64, 0, len(values))
negative = negativeValues(negative, values)
for i := range negative {
negative[i] = math.Pow(negative[i], 2)
}
x := stat.Mean(negative, nil)
return math.Sqrt(x) * math.Sqrt(periodsPerYear)
}

func SortinoRatio(portfolio, riskFree []float64, downsideVol, periodsPerYear float64) float64 {
pr := AnnualizedArithmeticReturn(portfolio, periodsPerYear)
rr := AnnualizedArithmeticReturn(riskFree, periodsPerYear)
return (pr - rr) / downsideVol
}

func MaxDrawdown(values []float64) (float64, int) {
retained := retainedAfterDrawdown(values)
i := floats.MinIdx(retained)
ret := retained[i]
return 1 - ret, i
}

func CalmarRatio(portfolio, riskFree []float64, maxDrawdown, periodsPerYear float64) float64 {
pr := AnnualizedArithmeticReturn(portfolio, periodsPerYear)
rr := AnnualizedArithmeticReturn(riskFree, periodsPerYear)
return (pr - rr) / maxDrawdown
}

func UlcerIndex(values []float64, periodsPerYear float64) float64 {
retained := retainedAfterDrawdown(values)
for i := range retained {
retained[i] = math.Pow(retained[i], 2)
}
x := stat.Mean(retained, nil)
return math.Sqrt(x) * math.Sqrt(periodsPerYear)
}

func TrackingError(excessReturns []float64, periodsPerYear float64) float64 {
return stat.PopStdDev(excessReturns, nil) * math.Sqrt(periodsPerYear)
}

func InformationRatio(portfolio, benchmark []float64, periodsPerYear float64) float64 {
pr := AnnualizedTimeWeightedReturn(portfolio, periodsPerYear)
br := AnnualizedTimeWeightedReturn(benchmark, periodsPerYear)
er := pr - br

excess := make([]float64, len(portfolio))
floats.SubTo(excess, portfolio, benchmark)
te := TrackingError(excess, periodsPerYear)

return er / te
}

func BetaToBenchmark(portfolio, benchmark []float64) float64 {
_, slope := stat.LinearRegression(benchmark, portfolio, nil, false)
return slope
}

func ValueAtRisk(values []float64, portfolioValue, confidenceLevel, periodsPerYear float64) float64 {
normal := distuv.Normal{
Mu: 0,
Sigma: 1.0,
}
zScore := normal.Quantile(confidenceLevel)
return portfolioValue * zScore * AnnualizeRisk(stat.PopStdDev(values, nil), periodsPerYear)
}

func negativeValues(out, in []float64) []float64 {
for _, v := range in {
if v < 0 {
out = append(out, v)
}
}
return out
}

func retainedAfterDrawdown(values []float64) []float64 {
cum := make([]float64, len(values))
for i := range values {
if i == 0 {
cum[i] = 1 + values[i]
} else {
cum[i] = cum[i-1] * (1 + values[i])
}
}
retained := make([]float64, len(values))
for i := range cum {
if i == 0 {
retained[i] = 1
continue
}
x := floats.Max(cum[0:i])
if x < 1 {
x = 1
}
retained[i] = cum[i] / x
}
return retained
}
120 changes: 120 additions & 0 deletions calculate/metrics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package calculate

import (
"bytes"
"encoding/csv"
"errors"
"io"
"os"
"path/filepath"
"strconv"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gonum.org/v1/gonum/floats"
)

func TestMetrics(t *testing.T) {
data, err := loadTestdataReturns(filepath.FromSlash("testdata/metrics.tsv"), 3)
require.NoError(t, err)
cash, portfolio, benchmark := data[0], data[1], data[2]
assert.Len(t, cash, 10_000)
assert.Len(t, portfolio, 10_000)
assert.Len(t, benchmark, 10_000)

// Downside Volatility 13.03% =SQRT(AVERAGE(K2:K10001))*SQRT($N$1)
t.Run("Downside Volatility", func(t *testing.T) {
result := DownsideVolatility(portfolio, PeriodsPerYear)
assert.InDelta(t, 0.1303, result, 0.0001)
})

// Sortino Ratio 0.17 =(N6-N5)/N12
t.Run("Sortino Ratio", func(t *testing.T) {
downsideVol := DownsideVolatility(portfolio, PeriodsPerYear)
result := SortinoRatio(portfolio, cash, downsideVol, PeriodsPerYear)
assert.InDelta(t, 0.17, result, 0.01)
})

// Calmar Ratio 0.06 =N7/N17
t.Run("Calmar Ratio", func(t *testing.T) {
maxDrawdown, _ := MaxDrawdown(portfolio)
result := CalmarRatio(portfolio, cash, maxDrawdown, PeriodsPerYear)
assert.InDelta(t, 0.059, result, 0.001)
})

// Ulcer Index 13.82 =SQRT(AVERAGE(I2:I10001))*SQRT(N1)
t.Run("Ulcer Index", func(t *testing.T) {
result := UlcerIndex(portfolio, PeriodsPerYear)
assert.InDelta(t, 13.82, result, 0.01)
})

// Max Drawdown 37.38% =1-MIN(H2:H10001)
t.Run("Max Drawdown", func(t *testing.T) {
result, _ := MaxDrawdown(portfolio)
assert.InDelta(t, 0.3738, result, 0.0001)
})

// Tracking Error 6.27% =STDEV.P(F2:F10001)*SQRT(N1)
t.Run("Tracking Error", func(t *testing.T) {
excess := make([]float64, len(portfolio))
floats.SubTo(excess, portfolio, benchmark)
result := TrackingError(excess, PeriodsPerYear)
assert.InDelta(t, 0.0627, result, 0.0001)
})

// Information Ratio 0.06 =N9/N10
t.Run("Information Ratio", func(t *testing.T) {
result := InformationRatio(portfolio, benchmark, PeriodsPerYear)
assert.InDelta(t, 0.06, result, 0.01)
})

// Beta to Benchmark 0.75 =SLOPE(D2:D10001,E2:E10001)
t.Run("Beta to Benchmark", func(t *testing.T) {
result := BetaToBenchmark(portfolio, benchmark)
assert.InDelta(t, 0.748, result, 0.001)
})

// VaR (5% Confidence) (2,148.94) =N18*N11*N2
t.Run("Value at Risk aka VaR", func(t *testing.T) {
const (
portfolioValue = 10_000.00
confidenceLevel = 0.95
)
result := ValueAtRisk(portfolio, portfolioValue, confidenceLevel, PeriodsPerYear)
assert.InDelta(t, 2148.94, result, 0.01)
})
}

func loadTestdataReturns(fileName string, columns int) ([][]float64, error) {
buf, err := os.ReadFile(fileName)
if err != nil {
return nil, err
}
r := csv.NewReader(bytes.NewBuffer(buf))
r.FieldsPerRecord = columns
r.Comma = '\t'
r.ReuseRecord = true
r.TrimLeadingSpace = true
data := make([][]float64, columns)
for i := 0; ; i++ {
line, err := r.Read()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return nil, err
}
if i < 1 || len(line) != 3 {
continue
}
for j := range line {
f, err := strconv.ParseFloat(line[j], 64)
if err != nil {
return nil, err
}
data[j] = append(data[j], f)
}
}
return data, nil
}
Loading

0 comments on commit 808d2aa

Please sign in to comment.