diff --git a/allocation/risk.go b/allocation/risk.go index 5df61fd..132d136 100644 --- a/allocation/risk.go +++ b/allocation/risk.go @@ -36,7 +36,7 @@ func (*EqualRiskContribution) PolicyWeights(ctx context.Context, _ time.Time, as copy(weights, ws) return weights, optWeights(ctx, weights, func(ws []float64) float64 { - _, _, riskWeights := calculations.RiskFromRiskContribution(assetRisks, ws, cm) + riskWeights := calculations.RiskWeights(calculations.PortfolioVolatility(assetRisks, ws, cm)) var diff float64 for i := range riskWeights { diff += math.Abs(target - riskWeights[i]) diff --git a/calculations/correlalation.go b/calculations/correlalation.go deleted file mode 100644 index 3f0f3d0..0000000 --- a/calculations/correlalation.go +++ /dev/null @@ -1,49 +0,0 @@ -package calculations - -import ( - "math" - - "gonum.org/v1/gonum/mat" - "gonum.org/v1/gonum/stat" -) - -func CorrelationMatrix(values [][]float64) *mat.Dense { - if len(values) == 0 { - return nil - } - m := mat.NewDense(len(values), len(values), nil) - mp := make(map[int][]float64, len(values)) - minimum := math.MaxInt - for i := range values { - mp[i] = values[i] - if len(mp[i]) < minimum { - minimum = len(mp[i]) - } - } - for i := 0; i < len(values); i++ { - for j := i; j < len(values); j++ { - if i == j { - m.Set(i, j, 1) - continue - } - vi, vj := mp[i], mp[j] - c := stat.Correlation(vi[:minimum], vj[:minimum], nil) - m.Set(i, j, c) - m.Set(j, i, c) - } - } - return m -} - -func DenseToSlices(dense *mat.Dense) [][]float64 { - if dense == nil { - return nil - } - iL, jL := dense.Dims() - result := make([][]float64, iL) - d := dense.RawMatrix().Data - for i := range result { - result[i] = d[i*jL : (i+1)*jL] - } - return result -} diff --git a/calculations/correlation.go b/calculations/correlation.go new file mode 100644 index 0000000..ce3b2fc --- /dev/null +++ b/calculations/correlation.go @@ -0,0 +1,29 @@ +package calculations + +import ( + "gonum.org/v1/gonum/stat" +) + +func CorrelationMatrix(values [][]float64) [][]float64 { + n := len(values) + if len(values) == 0 { + return nil + } + m := make([][]float64, n) + for i := range m { + m[i] = make([]float64, n) + } + for i := 0; i < n; i++ { + for j := i; j < n; j++ { + if i == j { + m[i][j] = 1.0 + continue + } + vi, vj := values[i], values[j] + c := stat.Correlation(vi, vj, nil) + m[i][j] = c + m[j][i] = c + } + } + return m +} diff --git a/calculations/risk.go b/calculations/risk.go index 72a6a2c..38b2479 100644 --- a/calculations/risk.go +++ b/calculations/risk.go @@ -2,117 +2,12 @@ package calculations import ( "errors" - "fmt" "math" + "slices" - "gonum.org/v1/gonum/floats" - "gonum.org/v1/gonum/mat" "gonum.org/v1/gonum/stat" ) -func DenseMatrixToFloatSlices(dense *mat.Dense) [][]float64 { - if dense == nil { - return make([][]float64, 0) - } - iLength, jLength := dense.Dims() - result := make([][]float64, iLength) - d := dense.RawMatrix().Data - for i := range result { - result[i] = d[i*jLength : (i+1)*jLength] - } - return result -} - -func Risk(risks, weights []float64, correlations [][]float64) (float64, error) { - numberOfAssets := len(risks) - - for i, r := range risks { - risks[i] = r / 100.0 - } - if sum := floats.Sum(weights); math.Abs(100.0-sum) > 0.0001 { - return 0, fmt.Errorf(`weights must add up to 100 but got %.2f`, sum) - } - for i, w := range weights { - weights[i] = w / 100.0 - } - - if len(weights) != numberOfAssets { - return 0, errors.New("the number of risks must be the same as the number of weights") - } - - if len(correlations) != numberOfAssets { - return 0, fmt.Errorf("correlations must be n by n where n is the length of risks; the number of rows is should be %d", numberOfAssets) - } - - cor := mat.NewDense(len(risks), len(risks), nil) - for i, row := range correlations { - if len(row) != numberOfAssets { - return 0, fmt.Errorf("correlations must be n by n where n is the length of risks; row %d has %d it should have %d", i, len(row), numberOfAssets) - } - for j, v := range correlations[i] { - if v < -1 || v > 1 { - return 0, fmt.Errorf("correlation values must be within [-1, 1] the value %.2f at row %d column %d is not in this range", v, i+1, j+1) - } - cor.Set(i, j, v) - } - } - - r, _, _ := RiskFromRiskContribution(risks, weights, cor) - - return r, nil -} - -func RiskFromRiskContribution(risks, weights []float64, correlations *mat.Dense) (float64, []float64, []float64) { - n := len(risks) - nSquared := n * n - - // START memory allocation stuff - bufferSize := 3*nSquared + 3*n - b := make([]float64, bufferSize) - b1 := b[:nSquared:nSquared] - b = b[len(b1):] - b2 := b[:nSquared:nSquared] - b = b[len(b2):] - b3 := b[:n:n] - b = b[len(b3):] - b4 := b[:nSquared:nSquared] - b = b[len(b4):] - v := b[:n:n] - b = b[len(v):] - rw := b[:n:n] - // END memory allocation stuff - - d := mat.NewDense(n, n, b1) - - for i := 0; i < len(risks); i++ { - d.Set(i, i, risks[i]) - } - - V := mat.NewDense(n, n, b2) - V.Product(d, correlations, d) - - mx1 := mat.NewDense(n, 1, b3) - mx1.Product(V, mat.NewDense(n, 1, weights)) - mx2 := mat.NewDense(n, n, b4) - mx2.Product(mx1, mat.NewDense(1, n, weights)) - - for i := 0; i < n; i++ { - v[i] = mx2.At(i, i) - } - pv := floats.Sum(v) - copy(rw, v) - for i := range rw { - rw[i] /= pv - } - - return math.Sqrt(pv), v, rw -} - -func ExpectedRisk(risks, weights []float64, correlations *mat.Dense) float64 { - r, _, _ := RiskFromRiskContribution(risks, weights, correlations) - return r -} - func NumberOfBets(weightedAverageRisk float64, portfolioRisk float64) (float64, error) { if portfolioRisk == 0 { return 0, errors.New("can't divide by 0") @@ -136,3 +31,50 @@ func RiskFromStdDev(values []float64) float64 { func WeightedAverageRisk(weights, risks []float64) float64 { return stat.Mean(risks, weights) } + +func RiskWeights(portfolioVol float64, riskContributions []float64) []float64 { + result := slices.Clone(riskContributions) + for i := range result { + result[i] = result[i] / portfolioVol + } + return result +} + +func PortfolioVolatility(weights, stdDevs []float64, correlations [][]float64) (float64, []float64) { + var variance float64 + n := len(weights) + covariances := calculateCovarianceMatrix(weights, stdDevs, correlations) + + for i := 0; i < n; i++ { + for j := 0; j < n; j++ { + variance += covariances[i][j] + } + } + + totalRisk := math.Sqrt(variance) + riskContributions := make([]float64, n) + + for i := 0; i < n; i++ { + for j := 0; j < n; j++ { + riskContributions[i] += covariances[i][j] + } + riskContributions[i] = riskContributions[i] / totalRisk + } + + return totalRisk, riskContributions +} + +func calculateCovarianceMatrix(weights, vols []float64, correlations [][]float64) [][]float64 { + n := len(weights) + covariances := make([][]float64, n) + for i := range covariances { + covariances[i] = make([]float64, n) + } + + for i := 0; i < n; i++ { + for j := 0; j < n; j++ { + covariances[i][j] = weights[i] * weights[j] * vols[i] * vols[j] * correlations[i][j] + } + } + return covariances +} diff --git a/calculations/risk_test.go b/calculations/risk_test.go index 84c98e2..61a8ccc 100644 --- a/calculations/risk_test.go +++ b/calculations/risk_test.go @@ -2,34 +2,14 @@ package calculations import ( "fmt" - "math" "testing" "github.com/portfoliotree/round" "github.com/stretchr/testify/assert" - - "gonum.org/v1/gonum/floats" - "gonum.org/v1/gonum/mat" ) func TestRisk(t *testing.T) { - t.Run("simple", func(t *testing.T) { - risk, err := Risk( - []float64{10, 10}, - []float64{100, 0.0}, - [][]float64{ - {1, 1}, - {1, 1}, - }, - ) - if err != nil { - t.Errorf("it should not return an error: %s", err) - } - - if math.IsNaN(risk) { - t.Errorf("risk should be a number got: %f", risk) - } - }) + } func ExampleRiskFromStdDev() { @@ -108,26 +88,63 @@ func TestNumberOfBets_Zero_Portfolio_Risk(t *testing.T) { } } -func TestRiskFromRiskContribution(t *testing.T) { - ws := []float64{-.1, .8, .3} - rs := []float64{.2, .05, .25} - cs := mat.NewDense(3, 3, []float64{ - 1.00, 0.15, 0.60, - 0.15, 1.00, 0.05, - 0.60, 0.05, 1.00, - }) - - const precision = 4 - tr, rcs, rw := RiskFromRiskContribution(rs, ws, cs) - tr = round.Decimal(tr, precision) - _ = round.Recursive(rcs, precision) - _ = round.Recursive(rw, precision) - sum := round.Decimal(floats.Sum(rw), precision) - - assert.Equal(t, rcs, []float64{-0.0006, 0.0016, 0.0049}) - assert.Equal(t, tr, .0767) - assert.Equal(t, rw, []float64{-0.1054, 0.2770, 0.8284}) - assert.Equal(t, sum, 1.0) +func TestPortfolioVolatility(t *testing.T) { + for _, tt := range []struct { + Name string + Weights []float64 + Risks []float64 + Correlations [][]float64 + + ExpectedPortfolioVolatility float64 + ExpectRiskContributions []float64 + }{ + { + Name: "legacy example", + Weights: []float64{-.1, .8, .3}, + Risks: []float64{.2, .05, .25}, + Correlations: [][]float64{ + {1.00, 0.15, 0.60}, + {0.15, 1.00, 0.05}, + {0.60, 0.05, 1.00}, + }, + ExpectRiskContributions: []float64{-0.0081, 0.0212, 0.0635}, + ExpectedPortfolioVolatility: .0767, + }, + { + Name: "two", + Weights: []float64{0.5, 0.5}, + Risks: []float64{0.1668, 0.0428}, + Correlations: [][]float64{ + {1.00, 0.25}, + {0.25, 1.00}, + }, + ExpectRiskContributions: []float64{0.0812, 0.0099}, + ExpectedPortfolioVolatility: .0911, + }, + { + Name: "three", + Weights: []float64{0.2500, 0.5000, 0.2500}, + Risks: []float64{0.0428, 0.1668, 0.1693}, + Correlations: [][]float64{ + {1.0000, 0.2465, 0.4077}, + {0.2465, 1.0000, 0.1091}, + {0.4077, 0.1091, 1.0000}, + }, + ExpectRiskContributions: []float64{0.0051, 0.0740, 0.0231}, + ExpectedPortfolioVolatility: .1022, + }, + } { + t.Run(tt.Name, func(t *testing.T) { + portfolioVol, riskContributions := PortfolioVolatility(tt.Weights, tt.Risks, tt.Correlations) + + const precision = 4 + portfolioVol = round.Decimal(portfolioVol, precision) + _ = round.Recursive(riskContributions, precision) + + assert.Equal(t, tt.ExpectRiskContributions, riskContributions) + assert.Equal(t, tt.ExpectedPortfolioVolatility, portfolioVol) + }) + } } // func TestLegacyRiskFromRiskContribution(t *testing.T) { diff --git a/returns/returns_legacy_test.go b/returns/returns_legacy_test.go index cd7d818..de99b62 100644 --- a/returns/returns_legacy_test.go +++ b/returns/returns_legacy_test.go @@ -5,13 +5,14 @@ import ( "fmt" "math" "math/rand" + "slices" "sort" "strconv" "testing" "time" + "github.com/portfoliotree/round" "github.com/stretchr/testify/assert" - "gonum.org/v1/gonum/mat" "github.com/portfoliotree/portfolio/internal/fixtures" "github.com/portfoliotree/portfolio/returns" @@ -47,40 +48,26 @@ func TestReturns_FirstAndLastPeriod(t *testing.T) { assert.Equal(t, end, date("2020-01-04")) } -func toSlices(dense *mat.Dense) [][]float64 { - iL, jL := dense.Dims() - result := make([][]float64, iL) - d := dense.RawMatrix().Data - for i := range result { - result[i] = d[i*jL : (i+1)*jL] - } - return result -} - func TestComposite_correlationMatrix(t *testing.T) { t.Run("perfectly positively correlated", func(t *testing.T) { - assert.Equal(t, - toSlices(returns.NewTable([]returns.List{ - {{Time: fixtures.T(t, fixtures.Day3), Value: 10}, {Time: fixtures.T(t, fixtures.Day2), Value: 20}, {Time: fixtures.T(t, fixtures.Day1), Value: 10}, {Time: fixtures.T(t, fixtures.Day0), Value: 20}}, - {{Time: fixtures.T(t, fixtures.Day3), Value: 10}, {Time: fixtures.T(t, fixtures.Day2), Value: 20}, {Time: fixtures.T(t, fixtures.Day1), Value: 10}, {Time: fixtures.T(t, fixtures.Day0), Value: 20}}, - }).CorrelationMatrix()), - [][]float64{ - {1, 1}, - {1, 1}, - }, - ) + assert.Equal(t, [][]float64{ + {1, 1}, + {1, 1}, + }, returns.NewTable([]returns.List{ + {{Time: fixtures.T(t, fixtures.Day3), Value: 10}, {Time: fixtures.T(t, fixtures.Day2), Value: 20}, {Time: fixtures.T(t, fixtures.Day1), Value: 10}, {Time: fixtures.T(t, fixtures.Day0), Value: 20}}, + {{Time: fixtures.T(t, fixtures.Day3), Value: 10}, {Time: fixtures.T(t, fixtures.Day2), Value: 20}, {Time: fixtures.T(t, fixtures.Day1), Value: 10}, {Time: fixtures.T(t, fixtures.Day0), Value: 20}}, + }).CorrelationMatrix()) }) t.Run("perfectly negatively correlated", func(t *testing.T) { - assert.Equal(t, - toSlices(returns.NewTable([]returns.List{ + assert.Equal(t, [][]float64{ + {1, -1}, + {-1, 1}, + }, + returns.NewTable([]returns.List{ {{Time: fixtures.T(t, fixtures.Day3), Value: 10}, {Time: fixtures.T(t, fixtures.Day2), Value: 20}, {Time: fixtures.T(t, fixtures.Day1), Value: 10}, {Time: fixtures.T(t, fixtures.Day0), Value: 20}}, {{Time: fixtures.T(t, fixtures.Day3), Value: 20}, {Time: fixtures.T(t, fixtures.Day2), Value: 10}, {Time: fixtures.T(t, fixtures.Day1), Value: 20}, {Time: fixtures.T(t, fixtures.Day0), Value: 10}}, - }).CorrelationMatrix()), - [][]float64{ - {1, -1}, - {-1, 1}, - }, + }).CorrelationMatrix(), ) }) @@ -90,8 +77,8 @@ func TestComposite_correlationMatrix(t *testing.T) { {{Time: fixtures.T(t, fixtures.Day2), Value: -0.1}, {Time: fixtures.T(t, fixtures.Day1), Value: -0.1}, {Time: fixtures.T(t, fixtures.Day0), Value: .1}}, }).CorrelationMatrix() - roughlyEqual(t, c.At(0, 1), 0.5) - roughlyEqual(t, c.At(1, 0), 0.5) + roughlyEqual(t, c[0][1], 0.5) + roughlyEqual(t, c[1][0], 0.5) }) } @@ -107,42 +94,35 @@ func TestDateAlignedReturnsList_ExpectedRisk(t *testing.T) { }) t.Run("with two assets and one has no weight", func(t *testing.T) { + ts := []time.Time{fixtures.T(t, fixtures.Day2), fixtures.T(t, fixtures.Day1), fixtures.T(t, fixtures.Day0)} list := returns.NewTable([]returns.List{ - {{Value: 1}, {Value: -2.0 / 3}, {Value: .5}}, - {{Value: .5}, {Value: .3}, {Value: .5}}, + {{Time: ts[0], Value: 1}, {Time: ts[1], Value: -2.0 / 3}, {Time: ts[2], Value: .5}}, + {{Time: ts[0], Value: .5}, {Time: ts[1], Value: .3}, {Time: ts[2], Value: .5}}, }) - - assert.Equal(t, list.ExpectedRisk([]float64{1, 0}), list.List(0).Risk()) + weights := []float64{1, 0} + assert.Equal(t, list.List(0).Risk(), list.ExpectedRisk(weights)) }) t.Run("with two completely correlated assets", func(t *testing.T) { - list := returns.NewTable([]returns.List{ - {{Value: 1}, {Value: -2.0 / 3}, {Value: .5}}, - {{Value: 1}, {Value: -2.0 / 3}, {Value: .5}}, - }) + ts := []time.Time{fixtures.T(t, fixtures.Day2), fixtures.T(t, fixtures.Day1), fixtures.T(t, fixtures.Day0)} + rs := returns.List{{Time: ts[0], Value: 1}, {Time: ts[1], Value: -2.0 / 3}, {Time: ts[2], Value: .5}} + list := returns.NewTable([]returns.List{slices.Clone(rs), slices.Clone(rs)}) compositeRisk := list.ExpectedRisk([]float64{0.2, 0.8}) - assert.Equal(t, compositeRisk, list.List(0).Risk()) - assert.Equal(t, compositeRisk, list.List(1).Risk()) - }) - - t.Run("with equal risk contribution", func(t *testing.T) { - list := returns.NewTable([]returns.List{ - {{Value: 1.5}, {Value: -0.25}, {Value: 1}}, - {{Value: 1.5}, {Value: -0.25}, {Value: 1}}, - }) - - compositeRisk := list.ExpectedRisk([]float64{0.5, 0.5}) + const exp = 0.8553 - roughlyEqual(t, compositeRisk, list.List(0).Risk()) - roughlyEqual(t, compositeRisk, list.List(1).Risk()) + assert.Equal(t, exp, round.Decimal(list.List(0).Risk(), 4)) + assert.Equal(t, exp, round.Decimal(list.List(1).Risk(), 4)) + assert.Equal(t, exp, round.Decimal(compositeRisk, 4)) + assert.Equal(t, exp, round.Decimal(compositeRisk, 4)) }) t.Run("with scaled but correlated assets", func(t *testing.T) { + ts := []time.Time{fixtures.T(t, fixtures.Day2), fixtures.T(t, fixtures.Day1), fixtures.T(t, fixtures.Day0)} list := returns.NewTable([]returns.List{ - {{Value: 1.5}, {Value: -0.25}, {Value: 1}}, - {{Value: 3}, {Value: -0.5}, {Value: 2}}, + {{Time: ts[0], Value: 1.5}, {Time: ts[1], Value: -0.25}, {Time: ts[2], Value: 1}}, + {{Time: ts[0], Value: 3}, {Time: ts[1], Value: -0.5}, {Time: ts[2], Value: 2}}, }) weights := []float64{.5, .5} diff --git a/returns/table.go b/returns/table.go index a38de93..c67993d 100644 --- a/returns/table.go +++ b/returns/table.go @@ -13,8 +13,6 @@ import ( "go.mongodb.org/mongo-driver/bson" - "gonum.org/v1/gonum/mat" - "github.com/portfoliotree/round" "github.com/portfoliotree/portfolio/calculations" @@ -258,17 +256,13 @@ func (table Table) Lists() []List { return result } -func (table Table) CorrelationMatrix() *mat.Dense { +func (table Table) CorrelationMatrix() [][]float64 { return calculations.CorrelationMatrix(table.values) } -func (table Table) CorrelationMatrixValues() [][]float64 { - return calculations.DenseToSlices(table.CorrelationMatrix()) -} - func (table Table) ExpectedRisk(weights []float64) float64 { risks := table.RisksFromStdDev() - r, _, _ := calculations.RiskFromRiskContribution(risks, weights, table.CorrelationMatrix()) + r, _ := calculations.PortfolioVolatility(risks, weights, table.CorrelationMatrix()) return r } diff --git a/returns/table_test.go b/returns/table_test.go index 50416af..1c99481 100644 --- a/returns/table_test.go +++ b/returns/table_test.go @@ -321,7 +321,7 @@ AddColumn now does not add a column to the table if the table does not already h func TestTable_CorrelationMatrix(t *testing.T) { t.Run("empty", func(t *testing.T) { tab := returns.NewTable(nil) - assert.Len(t, tab.CorrelationMatrixValues(), 0) + assert.Len(t, tab.CorrelationMatrix(), 0) }) returnsFromQuotes := func(quotes ...float64) []float64 { if len(quotes) < 2 { @@ -340,7 +340,7 @@ func TestTable_CorrelationMatrix(t *testing.T) { {rtn(t, fixtures.Day2, rs1[2]), rtn(t, fixtures.Day1, rs1[1]), rtn(t, fixtures.Day0, rs1[0])}, {rtn(t, fixtures.Day2, rs2[2]), rtn(t, fixtures.Day1, rs2[1]), rtn(t, fixtures.Day0, rs2[0])}, }) - assert.Equal(t, table.CorrelationMatrixValues(), [][]float64{ + assert.Equal(t, table.CorrelationMatrix(), [][]float64{ {1, 1}, {1, 1}, }) @@ -352,7 +352,7 @@ func TestTable_CorrelationMatrix(t *testing.T) { {rtn(t, fixtures.Day2, rs1[2]), rtn(t, fixtures.Day1, rs1[1]), rtn(t, fixtures.Day0, rs1[0])}, {rtn(t, fixtures.Day2, rs2[2]), rtn(t, fixtures.Day1, rs2[1]), rtn(t, fixtures.Day0, rs2[0])}, }) - assert.Equal(t, table.CorrelationMatrixValues(), [][]float64{ + assert.Equal(t, table.CorrelationMatrix(), [][]float64{ {1, -1}, {-1, 1}, })