Post List

2017년 9월 4일 월요일

Tactical Asset Allocation with 'Risk Parity' and 'Target Volatility' (Python Code)



동적자산배분(Dynamic Asset Allocation)의 열풍을 불고 온 Resolve AM의 상품들은
Target Volatility(8%, 12%, 16%) 의 형태를 가지고 있습니다.


자산들의 변동성이 일정 수준이 되도록 맞추는 최적화 하는 구조입니다.

안정적인 포트폴리오를 원한다면 Target Vol을 낮게,
공격적인 포트폴리오를 원한다면 Target Vol을 높게 가져갈 수 있죠







이는 Risk Parity 와도 결합이 가능합니다.

포트폴리오의 변동성이 일정 수준이 되게 만든 다음,
각 종목 별 Risk Contribution이 같게 만드는 형태로 말이죠.

먼저 3~12개월 모멘텀을 이용하여 상위 5개 자산을 이용한
Risk Parity 포트폴리오를 구성합니다. [Link]





괜찮은 수익률을 보입니다.
그렇다면 한번 포트폴리오의 변동성을 볼까요?





위 그래프는 255일 롤링 변동성입니다.

뭐랄까, 포트폴리오의 변동성이 매우 들쭉날쭉한 모양입니다.
90년대 말 낮을때는 4%이던 변동성이
2010년 즈음엔 14~15%까지 치솟고,
최근에는 다시 5~6%대로 매우 낮아졌습니다.


그렇다면 이러한 포트폴리오의 변동성을 일정하게 유지하기 위한
목표변동성(Target Volatility) 로직을 설정해 줍니다.




목표변동성을 σ(target) 으로 설정하고 인내도를 λ 로 설정합니다.
예를 들어, 목표 변동성을 10%로 설정했고, 인내도를 1%로 설정했다면,

포트폴리오의 변동성을
10% * 0.99 = 9.9% 와, 10% * 1.01 = 10.1% 내에 위치하게 최적화 합니다.

물론 과거 변동성을 이용한 ex ante 값 기준으로 말이죠.


전체 코드로 나타내면 아래와 같습니다.
수익률 데이터인 rets,
목표변동성 값인 Target,
비중의 하한과 상한인 lb, ub 총 4개 input이 필요합니다.

먼저 rets값을 이용해 분산-공분산 행렬을 만듭니다.
그 후 부터는 밑에서 다시 설명드릴게요


def RP_TargetVol(rets, Target, lb, ub) :

    covmat = DataFrame.cov(rets)
    
    def annualize_scale(rets):
    
        med  = np.median(np.diff(rets.index.values))
        seconds = int(med.astype('timedelta64[s]').item().total_seconds())
        if seconds < 60:
            freq = 'second'.format(seconds)
        elif seconds < 3600:
            freq = 'minute'.format(seconds//60)
        elif seconds < 86400:
            freq = 'hour'.format(seconds//3600)
        elif seconds < 604800:
            freq = 'day'.format(seconds//86400)
        elif seconds < 2678400:
            freq = 'week'.format(seconds//604800)
        elif seconds < 7948800:
            freq = 'month'.format(seconds//2678400)
        else:
            freq = 'quarter'.format(seconds//7948800)
        
        def switch1(x): 
            return {
            'day' : 252,
            'week' : 52,
            'month' : 12,
            'quarter' : 4,
            }.get(x)
        
        return switch1(freq)


    #--- Risk Budget Portfolio Objective Function ---#

    def RiskParity_objective(x) :
            
            variance = x.T @ covmat @ x
            sigma = variance ** 0.5
            mrc = 1/sigma * (covmat @ x)
            rc = x * mrc
            a = np.reshape(rc, (len(rc), 1))
            risk_diffs = a - a.T
            sum_risk_diffs_squared = np.sum(np.square(np.ravel(risk_diffs)))
            return (sum_risk_diffs_squared)
            
    
    #--- Constraints ---#
     
    def TargetVol_const_lower(x) :
        
        variance = x.T @ covmat @ x
        sigma = variance ** 0.5 
        sigma_scale = sigma * np.sqrt(annualize_scale(rets)) 
        
        vol_diffs = sigma_scale - (Target * 0.95)
        return(vol_diffs)  

    def TargetVol_const_upper(x) :
        
        variance = x.T @ covmat @ x
        sigma = variance ** 0.5 
        sigma_scale = sigma * np.sqrt(annualize_scale(rets)) 
        
        vol_diffs = (Target * 1.05) - sigma_scale
        return(vol_diffs)      
    
    
    #--- Calculate Portfolio ---#
    
    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': TargetVol_const_lower},
                   {'type': 'ineq', 'fun': TargetVol_const_upper})
    options = {'ftol': 1e-20, 'maxiter': 5000}
    
    result = minimize(fun = RiskParity_objective,
                      x0 = x0,
                      method = 'SLSQP',
                      constraints = constraints,
                      options = options,
                      bounds = bnds)
    return(result.x)



annualize_scale함수는 굉장히 복잡해 보이지만 별거 없습니다.
수익률 데이터가 일간 데이터인지, 주간 데이터인지, 월간데이터인지 판단하고
나중에 계산된 최적화 변동성에 sqrt(T)를 해주기 위함입니다.

우리가 설정한 목표변동성은 연율화 기준이지만,
수익률 데이터를 통해 나오는 변동성은 연율화가 아니기 떄문에
σ * sqrt(T)를 통해 연율화로 바꿔 주어야 겠죠.

사실 R에서는 frequency 함수로 쉽게 구할 수 있는데
파이썬에서는 DIY로..... ㅠㅠ


def annualize_scale(rets):
    
        med  = np.median(np.diff(rets.index.values))
        seconds = int(med.astype('timedelta64[s]').item().total_seconds())
        if seconds < 60:
            freq = 'second'.format(seconds)
        elif seconds < 3600:
            freq = 'minute'.format(seconds//60)
        elif seconds < 86400:
            freq = 'hour'.format(seconds//3600)
        elif seconds < 604800:
            freq = 'day'.format(seconds//86400)
        elif seconds < 2678400:
            freq = 'week'.format(seconds//604800)
        elif seconds < 7948800:
            freq = 'month'.format(seconds//2678400)
        else:
            freq = 'quarter'.format(seconds//7948800)
        
        def switch1(x): 
            return {
            'day' : 252,
            'week' : 52,
            'month' : 12,
            'quarter' : 4,
            }.get(x)
        
        return switch1(freq)



다음은 목적함수인 Risk Parity 입니다.
자세한 설명은 이미 지난번에 했으므로 생략생략 [Link]


def RiskParity_objective(x) :
            
            variance = x.T @ covmat @ x
            sigma = variance ** 0.5
            mrc = 1/sigma * (covmat @ x)
            rc = x * mrc
            a = np.reshape(rc, (len(rc), 1))
            risk_diffs = a - a.T
            sum_risk_diffs_squared = np.sum(np.square(np.ravel(risk_diffs)))
            return (sum_risk_diffs_squared)



이번에는 제약함수인 Target Vol의 상한과 하한 설정입니다.
위에서 언급한 내용을 풀면 다음과 같습니다.




이걸 수식으로 뚝딱뚝딱 넣으면 아래와 같습니다.
계산된 weight(x)를 통해 variance = x'Ωx 가 구해지고
이 제곱근이 포트폴리오의 변동성입니다.

또한, 앞서 말한 것처럼 시계열이 연율화가 되어있지 않기 때문에
sqrt(T) 를 곱해 연율화를 해 줍니다.

여기서 λ 값은 5%, 즉
하한은 타겟 대비 95%, 상한은 타겟 대비 105%로 설정해 줍니다.

최적화된 포트폴리오의 변동성이 이 안에 오도록 만들어 주는거죠.


def TargetVol_const_lower(x) :
        
        variance = x.T @ covmat @ x
        sigma = variance ** 0.5 
        sigma_scale = sigma * np.sqrt(annualize_scale(rets)) 
        
        vol_diffs = sigma_scale - (Target * 0.95)
        return(vol_diffs)  

    def TargetVol_const_upper(x) :
        
        variance = x.T @ covmat @ x
        sigma = variance ** 0.5 
        sigma_scale = sigma * np.sqrt(annualize_scale(rets)) 
        
        vol_diffs = (Target * 1.05) - sigma_scale
        return(vol_diffs)



참고로 타겟볼에서는 비중의 합이 1이 되는 제약조건은 주지 않습니다.
모든 종목들의 변동성이 타겟보다 클 경우
어떻게 해도 목표변동성을 맞출 수가 없고,
그럴 경우 유일한 방법은 투자 금액을 낮추는 것이기 때문이죠.



마지막으로 모든 조건들을 쓰까쓰까 해준다음
minimize 함수를 통해 최적화를 해줍니다.

약간 빡센 최적화인 만큼 이터레이션 횟수를 넉넉하게 설정해 줍니다.


    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': TargetVol_const_lower},
                   {'type': 'ineq', 'fun': TargetVol_const_upper})
    options = {'ftol': 1e-20, 'maxiter': 5000}
    
    result = minimize(fun = RiskParity_objective,
                      x0 = x0,
                      method = 'SLSQP',
                      constraints = constraints,
                      options = options,
                      bounds = bnds)
    return(result.x)



요렇게 해준다음 비중이 구해지면, cash 비중을 추가해주어야 됩니다.
위에서 설명한 것처럼 비중의 합이 1이라는 제약조건을 주지 않아
포트폴리오 비중의 합이 들쭉날쭉 합니다.

따라서 '1 - 비중의 합' 만큼 현금자산에 투자하는 (혹은 투자하지 않는)
추가적인 작업을 해주어야 합니다.

이는 R의 return.portfolio 패키지에서도 비중의 합이 0이 되지 않을 경우
wts@cash = 1 - RowSums(cash) 를 해주는 것과 같습니다.

자세한 설명은 코드가 너무 길어져서.... 기존 글들 보고.... 알아서.....





wts_cash = DataFrame(1 - wts_tv.sum(axis = 1))
wts_cash.columns = ['Cash']
wts_new = pd.concat([wts_tv, wts_cash], axis = 1)

rets_cash = pd.DataFrame(data = np.repeat(0.00, rets.shape[0]).reshape(rets.shape[0], 1),
                         index = rets.index,
                         columns = ['Cash'])
rets_new = pd.concat([rets, rets_cash], axis = 1)



파란색이 기존의 결과, 주황색이 목표변동성 8%를 설정한 결과입니다.
기존에 비해 성과가 나아졌음이 보입니다.

(항상 더 성과가 좋다는건 오해오해요)






아래 그림은 타겟볼 포트폴리오의 ex ante 변동성 입니다.
최적화 조건인 8% 내외로 변동성이 계산됩니다.





다음은 실현변동성인 ex-post 변동성입니다. (255일 롤링 기준)
항상 목표변동성인 8%에 맞지는 않지만, 대충 그 수준에서 놉니다.

변동성이 일정수준에 맞춰저 있기에,
어느 시장환경에서도 안정적인 수익을 보일 수 있습니다.







어떤 시장 흔들림에도 동요하지 않는다.






RP와 RP_TargetVol 포트폴리오의 각 종목별 RC 값 비교입니다.
Risk Parity 포트폴리오가 잘 구성됨이 확인됩니다.






년도별 수익률과 통계값 이구요




Risk Parity
RP_Target Vol
Ann Ret (Arith)
7.18%
8.20%
Ann Ret (Geometric)
7.08%
8.19%
Ann Std
8.28%
8.12%
Sharpe
0.85
1.01
Win Ratio
38.73%
38.69%
MDD
-17.00%
-22.25%
Skewness
-0.3603
-0.2371
Kurtosis
7.53
4.71



현금. 혹은 투자하지 않는 비중입니다.
0 보다 큰 값은 현금 비중이라는 뜻으로,
시장의 변동성이 너무 크기 때문에, 목표 변동성을 맞춰주기 위해
강제적으로 투자를 하지 않는 경우입니다.

2008년 금융위기 이후 2009년 초에는 변동성이 너무 커서
75% 가량이나 현금으로 들고 있기도 합니다.

반면 0보다 작은 마이너스 값은 레버리지 비중으로써,
시장의 변동성이 너무 낮아, 목표 변동성을 맞추주기 위해
투자비중을 100% 보다 많게 합니다.

현실에서는 대차를 통해 현금을 빌린 후 주식을 더 살수도 있고,
레버리지  ETF를 살수도 있고,
사실 선물을 이용하면 가장 손쉽게 레버리지가 가능합니다.

물론 현금 투자비중을 일정 내로 제한하거나
전체 투자비중의 합이 100 미만, 즉 레버리지 불가 등의
제약조건도 위에 넣으면 손쉽게 구현 가능합니다.

결과값은 목적식에서 많이 틀어지겠지만요.....




마지막으로 타겟 변동성 각각 10%와 12% 포트폴리오의 결과값 입니다.
수익률이 전체적으로 증가했지만,

변동성과 MDD도 상당히 증가했습니다.
또한 상당한 레버리지를 사용해는 부담도 있습니다.


Target 10%

Target 12%


Risk Parity
Target 8%
Target 10%
Target 12%
Ann Ret (Arith)
7.18%
8.20%
10.33%
11.94%
Ann Ret (Geometric)
7.08%
8.19%
10.32%
11.87%
Ann Std
8.28%
8.12%
10.07%
12.01%
Sharpe
0.8545
1.008
1.025
0.9881
Win Ratio
38.73%
38.69%
38.70%
38.53%
MDD
-17.00%
-22.25%
-27.03%
-31.29%
Skewness
-0.36
-0.24
-0.22
-0.21
Kurtosis
7.53
4.71
4.87
4.66

댓글 없음:

댓글 쓰기