[PortfolioOptimization] Risk Parity


개요

Risk Parity 방식의 포트폴리오 최적화를 해 봅니다.

필요한 라이브러리를 가져옵니다. 여기서는 월간 데이터를 사용해서 장기 시계열에서 Risk Parity가 말이 되는 이야기인지 한번 보겠습니다.

import pandas as pd
import pandas_datareader.data as web
import datetime
import numpy as np
%matplotlib inline
import matplotlib.pyplot as plt
import math
import pyfolio as pf
import quantstats
plt.rcParams["figure.figsize"] = (10, 6) # (w, h)
import sys
from scipy.stats import rankdata
from scipy.stats import stats
from scipy.optimize import minimize

종목의 포트폴리오에 대한 위험 기여도(Risk Contribution)는 개별 종목 비중과 그 종목의 한계 위험 기여도(Marginal Risk Contribution)의 곱으로 나타납니다. 한계 위험 기여도는 종목 비중 1단위 증가 시 포트폴리오 변동성 증가량을 의미합니다. 위험 균형(Risk Parity) 전략은 포트폴리오 내 종목들의 위험 기여도가 같도록 하는 것이 목적입니다. 위험 기여도 계산을 위해서는 과거 수익률 데이터와 수익률 데이터의 공분산 행렬이 필요합니다. Bridgewater Associates의 All Weather 전략에서 사용하는 자산군이 주식, 국채, TIPS, 금, 회사채, 원자재 정도이므로 미국 주식, 선진국 주식, 신흥국 주식, 미국 10년물 국채, 미국 30년물 국채, 미국 회사채, 미국 물가채, 금, 원자재 데이터를 사용합니다. 장기 월간 데이터로 먼저 구성한 후, 다른 포스팅에서 목표 변동성을 적용해 실제 사용 가능한 ETF들로 더 구성해 보겠습니다.

Monthly_Return = pd.read_excel('MonthlyAssetClassReturn.xlsx')
Monthly_Return.head()
Data IndexBroker Call RateCPIT-BillsS&P 500 Total returnSmall Cap StocksMSCI EAFEEEMUS 10 YRUS Corp Bond Return Index...International Small Cap Value (Global B/M Small Low)International Large Cap Value (Global B/M Big Low)International Small High Mom (Global mom Small High)International Large High Mom (Global mom Small High)Merrill High YieldWorld StocksWorld ex USABuyWritePutWriteBitcoin
01900-01-31NaN0.0133330.00250.016413NaNNaNNaN0.000000NaN...NaNNaNNaNNaNNaNNaNNaNNaNNaNNaN
11900-02-28NaN0.0000000.00250.021138NaNNaNNaN0.011278NaN...NaNNaNNaNNaNNaNNaNNaNNaNNaNNaN
21900-03-31NaN0.0000000.00250.011084NaNNaNNaN0.009758NaN...NaNNaNNaNNaNNaNNaNNaNNaNNaNNaN
31900-04-30NaN0.0000000.00250.015894NaNNaNNaN-0.016107NaN...NaNNaNNaNNaNNaNNaNNaNNaNNaNNaN
41900-05-31NaN0.0000000.0025-0.044246NaNNaNNaN0.016023NaN...NaNNaNNaNNaNNaNNaNNaNNaNNaNNaN

5 rows × 50 columns

Monthly_Return = Monthly_Return.set_index('Data Index')
Mon_Return = Monthly_Return.loc[:, ['S&P 500 Total return','MSCI EAFE','EEM','US 10 YR','US 30 YR','US Corp Bond Return Index','TIPS Series','GOLD','GSCI']]
Mon_Return = Mon_Return.loc[Mon_Return.index >= '1973-02-28']
Mon_Return.head()
S&P 500 Total returnMSCI EAFEEEMUS 10 YRUS 30 YRUS Corp Bond Return IndexTIPS SeriesGOLDGSCI
Data Index
1973-02-28-0.0352120.0827210.261974-0.001738-0.1292610.0000480.0024140.2865760.043358
1973-03-310.0009310.006339-0.037417-0.0009270.0044510.005169-0.0032270.058617-0.023813
1973-04-30-0.038394-0.055983-0.1138340.0077700.0122360.0067080.0092470.0077520.060363
1973-05-31-0.0163740.0243660.043714-0.0108040.0018340.005844-0.0033270.2637360.182372
1973-06-30-0.0040160.0247910.0111880.005058-0.0195920.0038970.0041300.073913-0.016939

공분산 행렬을 구합니다.

covmat = pd.DataFrame.cov(Mon_Return)
covmat
S&P 500 Total returnMSCI EAFEEEMUS 10 YRUS 30 YRUS Corp Bond Return IndexTIPS SeriesGOLDGSCI
S&P 500 Total return0.0019741.449954e-030.0014954.382106e-050.0000480.0002680.000112-0.0000110.000426
MSCI EAFE0.0014502.440376e-030.001963-8.664340e-07-0.0000720.0002550.0001380.0005070.000660
EEM0.0014951.963353e-030.003817-1.482382e-04-0.0002960.0002340.0000940.0005060.000890
US 10 YR0.000044-8.664340e-07-0.0001485.535041e-040.0007720.0002990.0003240.000078-0.000205
US 30 YR0.000048-7.168022e-05-0.0002967.716103e-040.0013870.0004400.0004180.000025-0.000418
US Corp Bond Return Index0.0002682.553661e-040.0002342.993841e-040.0004400.0003430.0002220.0000820.000014
TIPS Series0.0001121.378930e-040.0000943.235982e-040.0004180.0002220.0003050.0001710.000099
GOLD-0.0000115.066890e-040.0005067.824909e-050.0000250.0000820.0001710.0033460.000799
GSCI0.0004266.601547e-040.000890-2.049167e-04-0.0004180.0000140.0000990.0007990.003638

비중과 공분산 행렬을 주면 위험 기여도를 (Risk Contribution) 구해주는 함수입니다.

def RC(weight, covmat) :
    weight = np.array(weight)
    # @: 행렬곱
    variance = weight.T @ covmat @ weight
    sigma = variance ** 0.5
    mrc = 1/sigma * (covmat @ weight)
    rc = weight * mrc
    rc = rc / rc.sum()
    return(rc)

가중치 x를 주면 위험 균형(Risk Parity)을 맞추기 위해서 각 자산별 위험 기여도 차이의 제곱합으로 쓰여지는 목적함수를 계산하고, 이론상으로 원하는 값인 0에 가까워지도록 최적화할 것입니다.

def RiskParityObjective(x) :
    
    variance = x.T @ covmat @ x
    sigma = variance ** 0.5
    mrc = 1/sigma * (covmat @ x)
    rc = x * mrc
    a = np.reshape(rc.to_numpy(), (len(rc), 1))
    risk_diffs = a - a.T
    # np.ravel: 다차원 배열 -> 1차원 배열
    # 1차원으로 풀어준 후에 제곱 -> 합 -> 목적함수
    sum_risk_diffs_squared = np.sum(np.square(np.ravel(risk_diffs)))
    return (sum_risk_diffs_squared)

레버리지가 없고, 공매도를 하지 않으며, 항상 보유 자금 전액 투자하는 것으로 가정하여 종목별 비중 합이 1이고, 각 종목 비중이 0 이상이라고 가정합니다. 나중에 레버리지와 공매도도 사용하고 일부 현금을 남기기도 하는 방식도 만들어 보겠습니다.

# 제약 조건: 비중 합 1
def SumConstraint(x):
    return (x.sum()-1.0)

# 제약 조건: 비중 0 이상
def LongOnly(x):
    return(x)

실제 최적화하는 부분입니다. 초기 비중, 제약 조건, 반복 횟수 등을 설정합니다.

def RiskParity(covmat) :
    
    x0 = np.repeat(1/covmat.shape[1], covmat.shape[1]) 
    constraints = ({'type': 'eq', 'fun': SumConstraint},
                  {'type': 'ineq', 'fun': LongOnly})
    options = {'ftol': 1e-20, 'maxiter': 2000}
    
    result = minimize(fun = RiskParityObjective,
                      x0 = x0,
                      method = 'SLSQP',
                      constraints = constraints,
                      options = options)
    return(result.x)

위험 균형을 맞춘 가중치입니다. 미국 주식 8.57%, 선진국 주식 7.07%, 신흥국 주식 6.49%로 합 22.13%, 미국 10년물 국채 15.75%, 미국 30년물 국채 11.23%, 미국 회사채 16.12%, 미국 TIPS 17.45%로 채권류 60.55%, 금 8.54%, 원자재 8.78%가 나옵니다. 1973년 2월부터 2020년 12월까지 전체 기간에 대한 Risk Parity를 만족하는 비중이니, 실제 사용 용도로는 Risk Parity를 계산하는 특정 기간이 있어야 할 것이고, 그 기간에 대하여 비중을 구하고 계속 바꾸어야 합니다.

RiskParity(covmat)
array([0.08568781, 0.07065772, 0.06493921, 0.1574744 , 0.11231389,
       0.16124245, 0.17451293, 0.08540713, 0.08776447])

Risk Contribution이 모두 균일하게 되었습니다.

weight_rp = RiskParity(covmat)
RC(weight_rp, covmat)
S&P 500 Total return         0.111111
MSCI EAFE                    0.111111
EEM                          0.111111
US 10 YR                     0.111111
US 30 YR                     0.111111
US Corp Bond Return Index    0.111111
TIPS Series                  0.111111
GOLD                         0.111111
GSCI                         0.111111
dtype: float64

동일 가중으로 했을 때는 Risk Contribution이 주식, 금, 원자재에 집중되고, 변동성이 낮았던 채권 쪽에서는 Risk Contribution이 낮습니다. 동일 가중으로 만든 전략은 주식 30%, 국채 20%, 회사채 10%, 물가채 10%, 금 10%, 원자재 10%라는 언뜻 보기에는 괜찮은 비중입니다. 그러나, 실상은 주식과 원자재에 의해 좌우되는 전략입니다.

weight_equal = np.repeat(1/Mon_Return.shape[1], Mon_Return.shape[1])
rc_equal = RC(weight_equal, covmat)

rc_equal
S&P 500 Total return         0.141023
MSCI EAFE                    0.178296
EEM                          0.207808
US 10 YR                     0.041680
US 30 YR                     0.055953
US Corp Bond Return Index    0.052443
TIPS Series                  0.045752
GOLD                         0.133649
GSCI                         0.143396
dtype: float64

전통의 자산배분 전략인 60:40은 주식이 88%의 위험을 담당합니다. 주식 100%보다는 하락장에서 방어가 된다고 하지만, 사실상 주식 전략입니다.

covmat_6040 = pd.DataFrame.cov(Mon_Return[['S&P 500 Total return', 'US 10 YR']])
weight_6040 = np.array([0.6, 0.4])
rc_6040 = RC(weight_6040, covmat_6040)

rc_6040
S&P 500 Total return    0.879188
US 10 YR                0.120812
dtype: float64

이제 실제 사용 용도로 기간별로 Risk Parity를 맞춘 비중을 구해 보겠습니다. 직전 12개월의 데이터로 Risk Parity를 맞추는 것으로 가정합니다. 지금은 월간 데이터라 직전 12개월 데이터를 사용하지만, 실제 ETF로 구현한다면 일간 데이터 사용이 가능하니 3개월 정도로 할 수도 있습니다.

RP_Mon_12 = Mon_Return
RP_Weight = []

for i in range(len(RP_Mon_12)):
    # use previous 12 months
    #print(i)
    if i < 12:
        RP_Weight.append('')
    else:
        covmat = pd.DataFrame.cov(RP_Mon_12.iloc[i-12 : i-1])
        temp = []
        temp.append(RiskParity(covmat))
        RP_Weight.append(temp)

RP_Mon_12['RP_Weight'] = RP_Weight    
RP_Mon_12
S&P 500 Total returnMSCI EAFEEEMUS 10 YRUS 30 YRUS Corp Bond Return IndexTIPS SeriesGOLDGSCIRP_Weight
Data Index
1973-02-28-0.0352120.0827210.261974-0.001738-0.1292610.0000480.0024140.2865760.043358
1973-03-310.0009310.006339-0.037417-0.0009270.0044510.005169-0.0032270.058617-0.023813
1973-04-30-0.038394-0.055983-0.1138340.0077700.0122360.0067080.0092470.0077520.060363
1973-05-31-0.0163740.0243660.043714-0.0108040.0018340.005844-0.0033270.2637360.182372
1973-06-30-0.0040160.0247910.0111880.005058-0.0195920.0038970.0041300.073913-0.016939
.................................
2020-08-310.0718800.0515390.022350-0.015141-0.063811-0.0167490.008899-0.0033730.045855[[0.05172668326045804, 0.0671069801855066, 0.0...
2020-09-30-0.037997-0.025543-0.0158070.0033770.008158-0.002266-0.003780-0.041966-0.036394[[0.042771887479128946, 0.06617528604281964, 0...
2020-10-30-0.026593-0.0398220.020757-0.016822-0.041348-0.003037-0.010039-0.003972-0.035747[[0.04458310762304897, 0.06315774927849194, 0....
2020-11-300.1094640.1551310.0925500.0044330.0174870.0357670.012841-0.0537450.120405[[0.04274533348779056, 0.06082893594437274, 0....
2020-12-310.0384490.0466580.073987-0.007572-0.0145390.0038890.0123000.0672310.059707[[0.043597308627403984, 0.05660072521560604, 0...

575 rows × 10 columns

Risk Parity를 맞춘 비중이 구해지는 기간만 남깁니다. 1974년 2월부터입니다.

RP_Mon_12 = RP_Mon_12.loc[RP_Mon_12.index >= '1974-02-28']
RP_Mon_12
S&P 500 Total returnMSCI EAFEEEMUS 10 YRUS 30 YRUS Corp Bond Return IndexTIPS SeriesGOLDGSCIRP_Weight
Data Index
1974-02-28-0.0006520.0333120.0410190.005127-0.0084170.0104170.0041560.225904-0.005311[[0.0713116312808524, 0.04055938332052815, 0.0...
1974-03-31-0.020301-0.025757-0.029591-0.021909-0.004631-0.014738-0.0117070.064496-0.115087[[0.04147101098293171, 0.03291792139630888, 0....
1974-04-30-0.0359440.026993-0.084748-0.011155-0.031023-0.0197280.000490-0.0219270.011066[[0.032531867437622985, 0.025352179436238673, ...
1974-05-31-0.030322-0.0388780.0121520.016212-0.019864-0.0062420.027536-0.073746-0.067649[[0.06551465668791748, 0.04688113790795395, 0....
1974-06-30-0.011324-0.0451290.029658-0.0020830.002104-0.0084410.008234-0.0796180.124165[[0.05997559825465769, 0.07049400910226089, 0....
.................................
2020-08-310.0718800.0515390.022350-0.015141-0.063811-0.0167490.008899-0.0033730.045855[[0.05172668326045804, 0.0671069801855066, 0.0...
2020-09-30-0.037997-0.025543-0.0158070.0033770.008158-0.002266-0.003780-0.041966-0.036394[[0.042771887479128946, 0.06617528604281964, 0...
2020-10-30-0.026593-0.0398220.020757-0.016822-0.041348-0.003037-0.010039-0.003972-0.035747[[0.04458310762304897, 0.06315774927849194, 0....
2020-11-300.1094640.1551310.0925500.0044330.0174870.0357670.012841-0.0537450.120405[[0.04274533348779056, 0.06082893594437274, 0....
2020-12-310.0384490.0466580.073987-0.007572-0.0145390.0038890.0123000.0672310.059707[[0.043597308627403984, 0.05660072521560604, 0...

563 rows × 10 columns

RP_Return = []
ret = 0
for i in range(len(RP_Mon_12)):
    # 자산군 수익률 * 자산군 비중. 9개 자산군
    for j in range(9):
        ret += RP_Mon_12.iloc[i,j]*RP_Mon_12['RP_Weight'][i][0][j]

    RP_Return.append(ret)
    ret = 0

RP_Mon_12['RP_Return'] = RP_Return
RP_Mon_12
S&P 500 Total returnMSCI EAFEEEMUS 10 YRUS 30 YRUS Corp Bond Return IndexTIPS SeriesGOLDGSCIRP_WeightRP_Return
Data Index
1974-02-28-0.0006520.0333120.0410190.005127-0.0084170.0104170.0041560.225904-0.005311[[0.0713116312808524, 0.04055938332052815, 0.0...0.015945
1974-03-31-0.020301-0.025757-0.029591-0.021909-0.004631-0.014738-0.0117070.064496-0.115087[[0.04147101098293171, 0.03291792139630888, 0....-0.024357
1974-04-30-0.0359440.026993-0.084748-0.011155-0.031023-0.0197280.000490-0.0219270.011066[[0.032531867437622985, 0.025352179436238673, ...-0.012420
1974-05-31-0.030322-0.0388780.0121520.016212-0.019864-0.0062420.027536-0.073746-0.067649[[0.06551465668791748, 0.04688113790795395, 0....-0.003765
1974-06-30-0.011324-0.0451290.029658-0.0020830.002104-0.0084410.008234-0.0796180.124165[[0.05997559825465769, 0.07049400910226089, 0....0.002105
....................................
2020-08-310.0718800.0515390.022350-0.015141-0.063811-0.0167490.008899-0.0033730.045855[[0.05172668326045804, 0.0671069801855066, 0.0...-0.002268
2020-09-30-0.037997-0.025543-0.0158070.0033770.008158-0.002266-0.003780-0.041966-0.036394[[0.042771887479128946, 0.06617528604281964, 0...-0.005682
2020-10-30-0.026593-0.0398220.020757-0.016822-0.041348-0.003037-0.010039-0.003972-0.035747[[0.04458310762304897, 0.06315774927849194, 0....-0.017969
2020-11-300.1094640.1551310.0925500.0044330.0174870.0357670.012841-0.0537450.120405[[0.04274533348779056, 0.06082893594437274, 0....0.029113
2020-12-310.0384490.0466580.073987-0.007572-0.0145390.0038890.0123000.0672310.059707[[0.043597308627403984, 0.05660072521560604, 0...0.011697

563 rows × 11 columns

동적으로 매달 움직여주면 샤프 비율이 1.31까지 올라갑니다. 이전에 테스트한 비중이 고정적인 All Seasons 전략의 샤프 비율 1.16보다 높습니다. 월간 데이터이므로, 일간 데이터 기준인 패키지가 주는 값을 적절히 조정해야 합니다. 1년 12개월 252거래일을 가정합니다. 1974년 2월부터 2020년 12월까지의 테스트입니다. 아래 그림의 제목 하단에 있는 샤프 비율은 무시하고, 직접 계산한 값을 보아야 합니다.

quantstats.stats.sharpe(RP_Mon_12['RP_Return'])/math.sqrt(252/12)
1.3082500298200432
quantstats.reports.plots(RP_Mon_12['RP_Return'], mode='basic')

output_29_0 output_29_1

** 주의: quantstats 라이브러리가 월간 데이터일 경우는 일부 지표를 이상한 값으로 돌려줍니다. 연간 기준으로 보정해서 생각해야 합니다.

연 복리 수익률 8.64%, 샤프 비율은 위에서 계산한대로 1.31 (아래 결과는 무시합니다 월간 데이터라 다르게 나옵니다), MDD는 -14.66%입니다. 연간 변동성은 샤프 비율을 보정한 것처럼 하면 6.51%입니다. 비중 고정 All Seasons 전략보다 변동성이 낮아졌고, 이는 target volatility를 설정하고 레버리지를 통해서 수익을 높일 수 있는 원천이 됩니다.

quantstats.reports.metrics(RP_Mon_12['RP_Return'], mode='full')
                           Strategy
-------------------------  ----------
Start Period               1974-02-28
End Period                 2020-12-31
Risk-Free Rate             0.0%
Time in Market             100.0%

Cumulative Return          4,756.04%
CAGR%                      8.64%
Sharpe                     6.0
Sortino                    11.14
Max Drawdown               -14.66%
Longest DD Days            639
Volatility (ann.)          29.83%
Calmar                     0.59
Skew                       -0.07
Kurtosis                   2.64

Expected Daily %           0.69%
Expected Monthly %         0.69%
Expected Yearly %          8.61%
Kelly Criterion            43.62%
Risk of Ruin               0.0%
Daily Value-at-Risk        -2.38%
Expected Shortfall (cVaR)  -2.38%

Payoff Ratio               1.31
Profit Factor              2.79
Common Sense Ratio         4.01
CPC Index                  2.48
Tail Ratio                 1.44
Outlier Win Ratio          3.5
Outlier Loss Ratio         3.19

MTD                        1.17%
3M                         1.66%
6M                         4.92%
YTD                        4.94%
1Y                         4.94%
3Y (ann.)                  6.42%
5Y (ann.)                  6.6%
10Y (ann.)                 5.08%
All-time (ann.)            8.64%

Best Day                   8.64%
Worst Day                  -9.04%
Best Month                 8.64%
Worst Month                -9.04%
Best Year                  26.73%
Worst Year                 -4.72%

Avg. Drawdown              -2.17%
Avg. Drawdown Days         108
Recovery Factor            324.39
Ulcer Index                0.95

Avg. Up Month              1.63%
Avg. Down Month            -1.24%
Win Days %                 68.03%
Win Month %                68.03%
Win Quarter %              78.19%
Win Year %                 85.11%





© 2021.03. by JacobJinwonLee

Powered by theorydb