[PortfolioOptimization] Risk Parity with Target Volatility


개요

Risk Parity 방식의 포트폴리오 최적화에 목표 변동성을 적용해 봅니다.

일간 데이터로 Risk Parity 방식에 목표 변동성 10%를 설정해 보겠습니다. 필요한 라이브러리들을 불러옵니다.

import pandas as pd
import pandas_datareader.data as web
import datetime
import backtrader as bt
import numpy as np
%matplotlib inline
import matplotlib.pyplot as plt
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 Parity) 전략을 목표 변동성(Target Volatility)을 맞추어 같이 사용할 수도 있습니다. 이럴 경우, 변동성이 매우 낮은 시장 상황이면 레버리지를 사용해 목표 변동성을 맞출 것이고, 변동성이 크다면 비중을 1보다 낮게 가져가 현금을 보유하도록 할 수 있습니다. 여기서는 포트폴리오 목표 변동성을 10%로 하고, 9.5% ~ 10.5% 사이에 들어오도록 맞추어 보겠습니다. 데이터는 시간이 오래 걸릴 수 있으니 미국 주식, 미국 장기 국채, 미국 물가채, 미국 단기 국채, 금, 원자재 데이터를 사용합니다. 미국 단기 국채는 현금 대용입니다. 가장 최근에 생긴 GSG ETF의 시작점부터 테스트합니다. 함수에는 자산 최대 보유 비중과 최소 보유 비중을 입력하도록 구현합니다.

start = '2006-07-21'
end = '2021-05-07'

vti = web.DataReader("VTI", 'yahoo', start, end)['Adj Close'].to_frame("vti")
tlt = web.DataReader("TLT", 'yahoo', start, end)['Adj Close'].to_frame("tlt")
tip = web.DataReader("TIP", 'yahoo', start, end)['Adj Close'].to_frame("tip")
iau = web.DataReader("IAU", 'yahoo', start, end)['Adj Close'].to_frame("iau")
gsg = web.DataReader("GSG", 'yahoo', start, end)['Adj Close'].to_frame("gsg")
shy = web.DataReader("SHY", 'yahoo', start, end)['Adj Close'].to_frame("shy")

가격 데이터로 수익률 데이터를 만듭니다.

price_df = pd.concat([vti, tlt, tip, iau, gsg], axis=1)
return_df = price_df.pct_change().dropna(axis=0)

cash_return_df = shy.pct_change().dropna(axis=0)
return_df.head()
vtitlttipiaugsg
Date
2006-07-240.019468-0.000586-0.000802-0.0124490.009137
2006-07-250.003370-0.0029320.0019060.009987-0.009054
2006-07-260.0027190.0036460.0033040.0050250.007513
2006-07-27-0.002552-0.0015230.0002000.0154840.010681
2006-07-280.0087950.0057510.0029920.003018-0.011964
cash_return_df.head()
shy
Date
2006-07-21-0.000251
2006-07-240.000000
2006-07-25-0.000250
2006-07-260.001255
2006-07-270.000501
def RiskParityTargetVol(rets, target, lb, ub) :

    covmat = pd.DataFrame.cov(rets)

    # 목적함수
    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))
            # 각 자산의 위험 기여도가 같도록
            # 아래쪽의 목적함수가 위험 기여도 차이의 제곱의 합이니 그것이 0에 가깝게 가야 함
            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)
    
    # 제약조건: 연간 변동성이 target vol의 95% 이상일 것
    def TargetVolLowerBound(x) :
        
        variance = x.T @ covmat @ x
        sigma = variance ** 0.5 
        sigma_scale = sigma * np.sqrt(252) 
        
        vol_diffs = sigma_scale - (target * 0.95)
        return(vol_diffs)  

    # 제약조건: 연간 변동성이 target vol의 105% 이하일 것
    def TargetVolUpperBound(x) :
        
        variance = x.T @ covmat @ x
        sigma = variance ** 0.5 
        sigma_scale = sigma * np.sqrt(252) 
        
        vol_diffs = (target * 1.05) - sigma_scale
        return(vol_diffs)      
    
    x0 = np.repeat(1/covmat.shape[1], covmat.shape[1]) 
    lbound  = np.repeat(lb, covmat.shape[1])
    ubound  = np.repeat(ub, covmat.shape[1])
    bnds = tuple(zip(lbound, ubound))
    constraints = ({'type': 'ineq', 'fun': TargetVolLowerBound},
                   {'type': 'ineq', 'fun': TargetVolUpperBound})
    options = {'ftol': 1e-20, 'maxiter': 2000}
    
    result = minimize(fun = RiskParityObjective,
                      x0 = x0,
                      method = 'SLSQP',
                      constraints = constraints,
                      options = options,
                      bounds = bnds)
    return(result.x)

위 함수는 목표 변동성 없이 구하는 위험 균형 함수와 비슷합니다. 그러나, 비중 합이 1이고 각 자산 비중이 0 이상이라는 제약 조건이 없으므로, 레버리지도 사용 가능합니다. 레버리지 사용 시 1을 초과하는 비중은 현금을 공매도한 것처럼 구현할 것입니다. 비중 합이 1보다 작은 경우 현금 보유를 해야 합니다. 편의상 현금 차입 비용은 미국 단기 국채 수익률로 대체할 것입니다.

자산 비중을 구하기 위한 rolling period는 자유롭게 변경 가능하지만, 편의상 62거래일(3개월)로 가정해 보겠습니다.

# 62 days(3 months) 기준으로 rolling 
weight_df = pd.DataFrame(columns=['vti','tlt','tip','iau','gsg'])

for i in range(len(return_df)-62):
    
    #print(i)
    
    # 63일 분량 데이터 잘라오기 (for문 돌 때마다 index 0~62, 1~63, 2~64, ...)
    temp_return_df = return_df.iloc[i:(i+63), :]
    
    # 63일 중 62일치를 계산에 사용하고 63번째 행은 날짜를 추출
    date_index = str(temp_return_df.iloc[62, :].name)[0:10]
    # Target vol 10% (0.1), 최소/최대 보유 비중은 0과 10 (Long Only, 10배 레버리지까지 허용)
    temp = pd.DataFrame(RiskParityTargetVol(temp_return_df.iloc[0:62, :], 0.1, 0, 10), index=['vti','tlt','tip','iau','gsg'], columns=[date_index]).T
    
    weight_df = pd.concat([weight_df, temp])

weight_df
vtitlttipiaugsg
2006-10-190.4592890.4925200.7174220.1598270.157035
2006-10-200.4983600.4894540.7113990.1537900.159656
2006-10-230.4968880.4847260.7146880.1520740.158535
2006-10-240.5065040.4802820.7187290.1495540.160160
2006-10-250.5025350.4811130.7132730.1494700.158289
..................
2021-05-030.2026950.2125710.4293560.1997310.155444
2021-05-040.2034900.2123660.4264810.1956210.155198
2021-05-050.2042710.2121700.4256450.1963300.154344
2021-05-060.2030460.2122270.4218740.1982110.154227
2021-05-070.2029150.2102110.4249830.1951440.154513

3662 rows × 5 columns

weight_df_final = weight_df

자산군 비중을 다 구했고, 현금 비중을 계산합니다. 현금은 각 자산군 비중 합계가 1보다 클 경우 레버리지를 구현하기 위해 차입한 것처럼 될 것이고, 비중 합계가 1보다 작을 경우 비중 총합을 1로 만들기 위하여 들어갈 것입니다.

weight_df_final['shy'] = 1 - weight_df_final.sum(axis = 1)
weight_df_final
vtitlttipiaugsgshy
2006-10-190.4592890.4925200.7174220.1598270.157035-0.986093
2006-10-200.4983600.4894540.7113990.1537900.159656-1.012660
2006-10-230.4968880.4847260.7146880.1520740.158535-1.006911
2006-10-240.5065040.4802820.7187290.1495540.160160-1.015230
2006-10-250.5025350.4811130.7132730.1494700.158289-1.004680
.....................
2021-05-030.2026950.2125710.4293560.1997310.155444-0.199797
2021-05-040.2034900.2123660.4264810.1956210.155198-0.193156
2021-05-050.2042710.2121700.4256450.1963300.154344-0.192761
2021-05-060.2030460.2122270.4218740.1982110.154227-0.189585
2021-05-070.2029150.2102110.4249830.1951440.154513-0.187765

3662 rows × 6 columns

weight_df_final.plot(title = 'Weights for each asset class')

만기가 길지는 않은 편이라 변동성이 낮은 TIPS ETF (TIP)가 비중을 많이 차지합니다. 종종 현금(SHY) 비중이 +가 되는데, 시장에 문제가 있는 구간으로 레버리지가 1배 이하인 구간이 됩니다.

output_14_1

sum_of_asset_weight = 1 - weight_df_final['shy']
sum_of_asset_weight.plot(title = 'Sum of weights for each asset group')

output_15_1

시장 충격이 있어서 변동성이 큰 시기에 시장 노출을 줄이고, 현금 비중을 크게 가져가는 것을 볼 수 있습니다. 레버리지를 많이 쓸 때는 3배 수준이고, 적게 쓸 때는 0.5~0.6배 정도까지 내려옵니다.

각 자산군의 가중치에 일간 수익률을 곱하고, 합산해서 risk parity with target volatility 전략의 모델 포트폴리오 수익률을 구합니다.

rp_tvol_return = (weight_df_final['vti'] * return_df.iloc[62:, :]['vti'] +
                  weight_df_final['tlt'] * return_df.iloc[62:, :]['tlt'] + 
                  weight_df_final['tip'] * return_df.iloc[62:, :]['tip'] +
                  weight_df_final['iau'] * return_df.iloc[62:, :]['iau'] +
                  weight_df_final['gsg'] * return_df.iloc[62:, :]['gsg'] + 
                  weight_df_final['shy'] * cash_return_df.iloc[62:, :]['shy'])
        
rp_tvol_return
2006-10-18         NaN
2006-10-19    0.004487
2006-10-20   -0.004732
2006-10-23   -0.005457
2006-10-24    0.005487
                ...   
2021-05-03    0.005104
2021-05-04    0.001649
2021-05-05    0.003304
2021-05-06    0.004849
2021-05-07    0.003809
Length: 3663, dtype: float64
rp_tvol_return.index
DatetimeIndex(['2006-10-18', '2006-10-19', '2006-10-20', '2006-10-23',
               '2006-10-24', '2006-10-25', '2006-10-26', '2006-10-27',
               '2006-10-30', '2006-10-31',
               ...
               '2021-04-26', '2021-04-27', '2021-04-28', '2021-04-29',
               '2021-04-30', '2021-05-03', '2021-05-04', '2021-05-05',
               '2021-05-06', '2021-05-07'],
              dtype='datetime64[ns]', length=3663, freq=None)

인덱스가 DatetimeIndex 형식이 아니므로 DatetimeIndex 형식으로 변환합니다.

rp_tvol_return.index = pd.to_datetime(rp_tvol_return.index)
rp_tvol_return.index
DatetimeIndex(['2006-10-18', '2006-10-19', '2006-10-20', '2006-10-23',
               '2006-10-24', '2006-10-25', '2006-10-26', '2006-10-27',
               '2006-10-30', '2006-10-31',
               ...
               '2021-04-26', '2021-04-27', '2021-04-28', '2021-04-29',
               '2021-04-30', '2021-05-03', '2021-05-04', '2021-05-05',
               '2021-05-06', '2021-05-07'],
              dtype='datetime64[ns]', length=3663, freq=None)
quantstats.reports.plots(rp_tvol_return, mode='basic')

output_23_0 output_23_1

아래 값들은 이 전략의 성과입니다. 구현 목표였던 연간 변동성 10%에 근접하게 나오고 있습니다. 목표 변동성을 더 올려주거나 차입 비용을 고려하기 위해 넣은 SHY ETF (미국 단기 국채) 수익률보다 더 낮은 수익률을 주는 현금 대용 자산을 넣으면 수익이 더 좋아질 것입니다.

quantstats.reports.metrics(rp_tvol_return, mode='full')
                           Strategy
-------------------------  ----------
Start Period               2006-10-18
End Period                 2021-05-07
Risk-Free Rate             0.0%
Time in Market             100.0%

Cumulative Return          204.92%
CAGR%                      7.96%
Sharpe                     0.8
Sortino                    1.11
Max Drawdown               -23.47%
Longest DD Days            1094
Volatility (ann.)          10.29%
Calmar                     0.34
Skew                       -0.67
Kurtosis                   4.42

Expected Daily %           0.03%
Expected Monthly %         0.64%
Expected Yearly %          7.22%
Kelly Criterion            6.79%
Risk of Ruin               0.0%
Daily Value-at-Risk        -1.03%
Expected Shortfall (cVaR)  -1.03%

Payoff Ratio               0.98
Profit Factor              1.14
Common Sense Ratio         1.16
CPC Index                  0.6
Tail Ratio                 1.01
Outlier Win Ratio          3.15
Outlier Loss Ratio         3.75

MTD                        1.89%
3M                         1.03%
6M                         6.86%
YTD                        1.44%
1Y                         15.8%
3Y (ann.)                  8.47%
5Y (ann.)                  9.76%
10Y (ann.)                 6.67%
All-time (ann.)            7.96%

Best Day                   2.62%
Worst Day                  -5.96%
Best Month                 7.57%
Worst Month                -12.6%
Best Year                  31.87%
Worst Year                 -12.5%

Avg. Drawdown              -2.18%
Avg. Drawdown Days         44
Recovery Factor            8.73
Ulcer Index                1.03

Avg. Up Month              2.63%
Avg. Down Month            -2.26%
Win Days %                 53.93%
Win Month %                60.23%
Win Quarter %              72.88%
Win Year %                 75.0%
quantstats.reports.html(rp_tvol_return, output='Report_RPTVOL.html', title='Risk Parity with 10% Target Vol')

아래는 tearsheet 링크입니다.

Report_RPTVOL.pdf




© 2021.03. by JacobJinwonLee

Powered by theorydb