-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
add more portfolio return metrics calculations
- Loading branch information
Showing
3 changed files
with
10,231 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Oops, something went wrong.