Post List

2017년 8월 23일 수요일

Tactical Asset Allocation (Python Code)



동적자산배분 중 가장 기본이 되는
10개 자산 중 12개월 모멘텀 상위 5개 종목에
동일비중을 배분하는 전략입니다.
[LINK]



먼저 패키지들 이것저것 import 합니다.
항상 쓰는 패키지들은 packages.py 같은 곳에 모아놓고,
from packages import * 로 읽어오면 훨씬 편합니다.


import pandas as pd
from pandas import Series, DataFrame
from pandas_datareader import data from pandas.tseries.offsets import Day, MonthEnd import numpy as np import sys import matplotlib.pyplot as plt from scipy.stats import rankdata






google에서 ETF 데이터들을 다운 받은 후,
수정주가만 모아서 수익률을 계산합니다.

아쉽게도 python에서는 yahoo 데이터 다운로드가 막혔으며.
따라서 배당이 반영된 수정주가도 구할수가 없습니다.





tickers = ['SPY', 'IEV', 'EWJ', 'EEM', 'TLO', 'IEF', 'IYR', 'RWX', 'GLD', 'DBC']
start = '2007-12-30'

all_data = {}
for ticker in tickers:
    all_data[ticker] = data.DataReader(ticker, 'google', start)

prices = pd.DataFrame({tic: data['Close'] for tic, data in all_data.items()})
prices = prices.fillna(method = 'ffill')
rets = prices.pct_change(1)



기본 옵션들 입니다.
거래비용, n개월 모멘텀, 상위 n개 종목입니다.




fee = 0.0030
lookback = 12
num = 5



월말 기준이 되는 지점을 찾습니다.
R에서 endpoints와 동일한 기능을 합니다.



s = pd.Series(np.arange(prices.shape[0]), index=prices.index)
ep = s.resample("M", how="max")



먼저, weight matrix를 만들어 줍니다.
각 월말 기준, 수익률 상위 5개 종목에 1/5 (20%)씩 비중을 배분합니다.


wts = list()

for i in range(lookback, len(ep)) :
    ## prices.index[ep[i]]       check the calendar
    cumret = prices.iloc[ep[i]] / prices.iloc[ep[i-12]] - 1
    K = rankdata(-cumret) <= num
    
    wt = np.repeat(0.00, prices.shape[1], axis = 0)
    wt[K] = 1 / num
    wt = pd.DataFrame(data = wt.reshape(1,prices.shape[1]),
                      index = [prices.index[ep[i]]],
                      columns = [prices.columns])
    wts.append(wt)
    
wts = pd.concat(wts)



가장 핵심이 되는 코드 입니다.
R에서 ReturnPortfolio 와 동일한 기능을 하는 함수 코드입니다.

R은 return matrix를 의미하며, weights는 위에서 만든 weight matrix를 의미합니다.

둘 간의 열 갯수는 같아야 하며 return의 frequency가 weight의 frequency 보다 같거나 high frequency 이면 됩니다.

결과물로써 포트폴리오의 수익률, 매시점 초와 말의 종목 별 비중을 반환합니다.

항상 쓰이는 함수이니 만큼, 다른 py 폴더에 저장해놓고 패키지처럼 불러오면 매우 편리합니다.

R 패키지의 BOP와 EOP contribution은 거의 사용하지 않아 작성하지 않았으며, verbose는 항상 True 라는 전제 하에 작성하였습니다.


def ReturnPortfolio(R, weights):
    if R.isnull().values.any() :
        print("NA's detected: filling NA's with zeros")
        R[np.isnan(R)] = 0

    if R.shape[1] != weights.shape[1] :
        print("Columns of Return and Weight is not same")        ## Check The Column Dimension
               
    if R.index[-1] < weights.index[0] + pd.DateOffset(days=1) :
        print("Last date in series occurs before beginning of first rebalancing period")
           
    if R.index[0] < weights.index[0] :
        R = R.loc[R.index > weights.index[0] + pd.DateOffset(days=1)]   ## Subset the Return object if the first rebalance date is after the first date 
     
    bop_value = pd.DataFrame(data = np.zeros(shape = (R.shape[0], R.shape[1])), index = R.index, columns = [R.columns])
    eop_value = pd.DataFrame(data = np.zeros(shape = (R.shape[0], R.shape[1])), index = R.index, columns = [R.columns])
    bop_weights = pd.DataFrame(data = np.zeros(shape = (R.shape[0], R.shape[1])), index = R.index, columns = [R.columns])
    eop_weights = pd.DataFrame(data = np.zeros(shape = (R.shape[0], R.shape[1])), index = R.index, columns = [R.columns])
    
    bop_value_total = pd.DataFrame(data = np.zeros(shape = R.shape[0]), index = R.index)
    eop_value_total = pd.DataFrame(data = np.zeros(shape = R.shape[0]), index = R.index)
    ret = pd.DataFrame(data = np.zeros(shape = R.shape[0]), index = R.index)
                       
    end_value = 1   # The end_value is the end of period total value from the prior period
    
    k = 0
    
    for i in range(0 , len(weights) -1 ) :
        fm = weights.index[i] + pd.DateOffset(days=1)
        to = weights.index[i + 1]            
        returns = R.loc[fm : to, ]

        jj = 0
        
        for j in range(0 , len(returns) ) :
            if jj == 0 :
                bop_value.iloc[k, :] = end_value * weights.iloc[i, :]
            else :
                bop_value.iloc[k, :] = eop_value.iloc[k-1, :]
            
            bop_value_total.iloc[k] = bop_value.iloc[k, :].sum()
                        
            # Compute end of period values
            eop_value.iloc[k, :] = (1 + returns.iloc[j, :]) * bop_value.iloc[k, :]
            eop_value_total.iloc[k] = eop_value.iloc[k, :].sum()
            
            # Compute portfolio returns
            ret.iloc[k] = eop_value_total.iloc[k] / end_value - 1
            end_value = float(eop_value_total.iloc[k])
            
            # Compute BOP and EOP weights
            bop_weights.iloc[k, :] = bop_value.iloc[k, :] / float(bop_value_total.iloc[k])
            eop_weights.iloc[k, :] = eop_value.iloc[k, :] / float(eop_value_total.iloc[k])
    
            jj += 1
            k += 1
    
    result = {'ret' : ret, 'bop_weights' : bop_weights, 'eop_weights' : eop_weights}
    return(result)


위에서 작성한 함수를 바탕으로 수익률과 턴오버를 구하며,
매매수수료가 고려된 net return을 계산합니다.


result = ReturnPortfolio(rets, wts)

portfolio_ret = result['ret']
turnover = pd.DataFrame((result['eop_weights'].shift(1) - result['bop_weights']).abs().sum(axis = 1))
portfolio_ret_net = portfolio_ret - (turnover * fee)


누적수익률과 누적 Drawdown을 구하는 함수입니다.
이 역시 거의 매번 쓰이는 함수이니 만큼,
다른 곳에 저장해놓고 불러오면 편합니다.

def ReturnCumulative(R) :
    R[np.isnan(R)] = 0
    
    temp = (1+R).cumprod()-1
    print("Total Return: ", round(temp.iloc[-1, :], 4)) 
    return(temp)

port_cumret = ReturnCumulative(portfolio_ret_net)


def drawdown(R) :
    dd = pd.DataFrame(data = np.zeros(shape = (R.shape[0], R.shape[1])), index = R.index, columns = [R.columns])
    R[np.isnan(R)] = 0
    
    for j in range(0, R.shape[1]):
        
        if (R.iloc[0, j] > 0) :
            dd.iloc[0, j] = 0
        else :
            dd.iloc[0, j] = R.iloc[0, j]
            
        for i in range(1 , len(R)):
            temp_dd = (1+dd.iloc[i-1, j]) * (1+R.iloc[i, j]) - 1
            if (temp_dd > 0) :
                dd.iloc[i, j] = 0
            else:
                dd.iloc[i, j] = temp_dd
    
    return(dd)
    
port_dd = drawdown(portfolio_ret_net)




위에서 작성한 누적수익률과 Drawdown을 바탕으로
그래프를 그려줍니다.





fig, axes = plt.subplots(2, 1)
port_cumret.plot(ax = axes[0], legend = None)
port_dd.plot(ax = axes[1], legend = None)



일별 수익률을 년도별 수익률로 바꾸는 함수와,
이를 이용하여 연도별 막대그래프를 그립니다.

R의 apply.yearly와 동일한 함수입니다.

월별 수익률로 변경하고 싶으면,
ep = s.resample("A").max() 에서 "A"를 "M"으로 변경하면 됩니다.




def apply_yearly(R) :
    
    s = pd.Series(np.arange(R.shape[0]), index=R.index)
    ep = s.resample("A").max()
    temp = pd.DataFrame(data = np.zeros(shape = (ep.shape[0], R.shape[1])), index = ep.index.year, columns = [R.columns])

    for i in range(0 , len(ep)) :
        if (i == 0) :
            sub_ret = R.iloc[ 0 : ep[i] + 1, :]
        else :
            sub_ret = R.iloc[ ep[i-1]+1 : ep[i] + 1, :]
        temp.iloc[i, ] = (1 + sub_ret).prod() - 1
    
    return(temp)

yr_ret = apply_yearly(portfolio_ret_net)
yr_ret.plot(kind = 'bar')




한번에 합치면 아래와 같습니다.

#--- Import Packages ---#
from pandas import Series, DataFrame
import pandas as pd
from pandas_datareader import data
from pandas.tseries.offsets import Day, MonthEnd
import numpy as np
import sys
import matplotlib.pyplot as plt
from scipy.stats import rankdata


#--- Download Raw Data ---#

tickers = ['SPY', 'IEV', 'EWJ', 'EEM', 'TLO', 'IEF', 'IYR', 'RWX', 'GLD', 'DBC']
start = '2007-12-30'

all_data = {}
for ticker in tickers:
    all_data[ticker] = data.DataReader(ticker, 'google', start)

prices = pd.DataFrame({tic: data['Close'] for tic, data in all_data.items()})
prices = prices.fillna(method = 'ffill')
rets = prices.pct_change(1)


#--- Basic Option ---#

fee = 0.0030
lookback = 12
num = 5


#--- Find Endpoints of Month ---#

s = pd.Series(np.arange(prices.shape[0]), index=prices.index)
ep = s.resample("M", how="max")


#--- Create Weight Matrix using 12M Momentum ---#

wts = list()

for i in range(lookback, len(ep)) :
    ## prices.index[ep[i]]       check the calendar
    cumret = prices.iloc[ep[i]] / prices.iloc[ep[i-12]] - 1
    K = rankdata(-cumret) <= num
    
    wt = np.repeat(0.00, prices.shape[1], axis = 0)
    wt[K] = 1 / num
    wt = pd.DataFrame(data = wt.reshape(1,prices.shape[1]),
                      index = [prices.index[ep[i]]],
                      columns = [prices.columns])
    wts.append(wt)
    
wts = pd.concat(wts)

#--- Portfolio Return Backtest Function ---#

def ReturnPortfolio(R, weights):
    if R.isnull().values.any() :
        print("NA's detected: filling NA's with zeros")
        R[np.isnan(R)] = 0

    if R.shape[1] != weights.shape[1] :
        print("Columns of Return and Weight is not same")        ## Check The Column Dimension
               
    if R.index[-1] < weights.index[0] + pd.DateOffset(days=1) :
        print("Last date in series occurs before beginning of first rebalancing period")
           
    if R.index[0] < weights.index[0] :
        R = R.loc[R.index > weights.index[0] + pd.DateOffset(days=1)]   ## Subset the Return object if the first rebalance date is after the first date 
     
    bop_value = pd.DataFrame(data = np.zeros(shape = (R.shape[0], R.shape[1])), index = R.index, columns = [R.columns])
    eop_value = pd.DataFrame(data = np.zeros(shape = (R.shape[0], R.shape[1])), index = R.index, columns = [R.columns])
    bop_weights = pd.DataFrame(data = np.zeros(shape = (R.shape[0], R.shape[1])), index = R.index, columns = [R.columns])
    eop_weights = pd.DataFrame(data = np.zeros(shape = (R.shape[0], R.shape[1])), index = R.index, columns = [R.columns])
    
    bop_value_total = pd.DataFrame(data = np.zeros(shape = R.shape[0]), index = R.index)
    eop_value_total = pd.DataFrame(data = np.zeros(shape = R.shape[0]), index = R.index)
    ret = pd.DataFrame(data = np.zeros(shape = R.shape[0]), index = R.index)
                       
    end_value = 1   # The end_value is the end of period total value from the prior period
    
    k = 0
    
    for i in range(0 , len(weights) -1 ) :
        fm = weights.index[i] + pd.DateOffset(days=1)
        to = weights.index[i + 1]            
        returns = R.loc[fm : to, ]

        jj = 0
        
        for j in range(0 , len(returns) ) :
            if jj == 0 :
                bop_value.iloc[k, :] = end_value * weights.iloc[i, :]
            else :
                bop_value.iloc[k, :] = eop_value.iloc[k-1, :]
            
            bop_value_total.iloc[k] = bop_value.iloc[k, :].sum()
                        
            # Compute end of period values
            eop_value.iloc[k, :] = (1 + returns.iloc[j, :]) * bop_value.iloc[k, :]
            eop_value_total.iloc[k] = eop_value.iloc[k, :].sum()
            
            # Compute portfolio returns
            ret.iloc[k] = eop_value_total.iloc[k] / end_value - 1
            end_value = float(eop_value_total.iloc[k])
            
            # Compute BOP and EOP weights
            bop_weights.iloc[k, :] = bop_value.iloc[k, :] / float(bop_value_total.iloc[k])
            eop_weights.iloc[k, :] = eop_value.iloc[k, :] / float(eop_value_total.iloc[k])
    
            jj += 1
            k += 1
    
    result = {'ret' : ret, 'bop_weights' : bop_weights, 'eop_weights' : eop_weights}
    return(result)


#--- Calculate Portfolio Return & Turnover ---#
    
result = ReturnPortfolio(rets, wts)

portfolio_ret = result['ret']
turnover = pd.DataFrame((result['eop_weights'].shift(1) - result['bop_weights']).abs().sum(axis = 1))
portfolio_ret_net = portfolio_ret - (turnover * fee)     


#--- Calculate Cumulative Return ---#

def ReturnCumulative(R) :
    R[np.isnan(R)] = 0
    
    temp = (1+R).cumprod()-1
    print("Total Return: ", round(temp.iloc[-1, :], 4)) 
    return(temp)

port_cumret = ReturnCumulative(portfolio_ret_net)


#--- Calculate Drawdown ---#

def drawdown(R) :
    dd = pd.DataFrame(data = np.zeros(shape = (R.shape[0], R.shape[1])), index = R.index, columns = [R.columns])
    R[np.isnan(R)] = 0
    
    for j in range(0, R.shape[1]):
        
        if (R.iloc[0, j] > 0) :
            dd.iloc[0, j] = 0
        else :
            dd.iloc[0, j] = R.iloc[0, j]
            
        for i in range(1 , len(R)):
            temp_dd = (1+dd.iloc[i-1, j]) * (1+R.iloc[i, j]) - 1
            if (temp_dd > 0) :
                dd.iloc[i, j] = 0
            else:
                dd.iloc[i, j] = temp_dd
    
    return(dd)
    
port_dd = drawdown(portfolio_ret_net)


#--- Graph: Portfolio Return and Drawdown ---#

fig, axes = plt.subplots(2, 1)
port_cumret.plot(ax = axes[0], legend = None)
port_dd.plot(ax = axes[1], legend = None)


#--- Daily Return Frequency To Yearly Return Frequency ---#

def apply_yearly(R) :
    
    s = pd.Series(np.arange(R.shape[0]), index=R.index)
    ep = s.resample("A").max()
    temp = pd.DataFrame(data = np.zeros(shape = (ep.shape[0], R.shape[1])), index = ep.index.year, columns = [R.columns])

    for i in range(0 , len(ep)) :
        if (i == 0) :
            sub_ret = R.iloc[ 0 : ep[i] + 1, :]
        else :
            sub_ret = R.iloc[ ep[i-1]+1 : ep[i] + 1, :]
        temp.iloc[i, ] = (1 + sub_ret).prod() - 1
    
    return(temp)

yr_ret = apply_yearly(portfolio_ret_net)
yr_ret.plot(kind = 'bar')

댓글 11개:

  1. 혹시 quandl에는 etf가격 없나요? 단순 호기심에서

    답글삭제
  2. 여러 퀀트 기법들을 파이썬으로 따라해 보고 있는데, 올려주신 코드가 많은 도움이 될 것 같습니다. 앞으로도 R 외에 파이썬으로도 많이 올려주시면 좋겠습니다. 전에 책 준비하고 있다고 한 것 같은데 언제 출간되나요?

    답글삭제
    답글
    1. 디자인 작업 하고있어서, 9월 말쯤에요

      삭제
  3. 선생님 안녕하세요.
    저는 해외퀀트매니저님 패캠 수업 같이 들었던 수강생입니다.
    페친 신청드렸고 블로그가 정말 유용해서 참고하면서 공부하고 있습니다. 좋은 자료들 정말 감사드립니다.

    위에 작성하신 파이썬 코드도 활용해보고 있습니다.
    F-score에 대해 위의 코드들을 적용해서 Back testing 해보았는데요.
    하다보니 수익률이 이상하게 떨어지는 결과가 발생해서요.
    이유를 간신히 찾았는데, 보니 wts 만들때 wt[K] = 1 / num 으로 배분을 하는데,

    이렇게 하면 F-score 점수가 겹쳐서 ranking이 같아졌을때,
    만약 num 숫자가 너무 작아서 같아진 ranking의 ticker들을 다 수용하지 못하면 오류가 생기더라구요.

    (예를들어, num = 2 이고 ticker a,b,c 가 있는데, ranking이 a 가 1등, b, c가 공동 3위라면,
    wts는 (0.5, 0, 0)이 되더라구요.)

    그래서 자산 합쳤다가 다시 분배하면 value가 0.5 된 것을 분배하게 되어
    portfolio_ret 값이 쭉쭉 떨어집니다.

    모멘텀의 경우는 숫자가 거의 겹칠 일이 없어서 아마 괜찮을 것 같긴 한데, 다른 factor를 scoring하여 전략을 구현하는 경우는 문제가 생길 것 같습니다.

    투자 몰빵 문제를 논외로 하여 1/num 을 1/sum(K) 로 바꾸면 문제가 해결되더라구요.

    모멘텀에서는 아주 마이너한 차이이지만 다른 전략 적용시는 문제가 커져 혹시나 참고되실까 하여 댓글 올려드립니다.

    분명 다 아셨겠지만, 혹시나 하여 미천한 첨언드립니다.

    블로그에 좋은 자료들 많이 올려주신 것에 다시 한 번 진심으로 감사드립니다 선생님. 많이 가르쳐주세요.

    답글삭제
    답글
    1. 아 반갑습니다.

      이게 R의 return.portfolio 패키지 그대로 따와서 만든건데, 이 방법에서는 weight의 합이 1 이 아니면 말씀하신거처럼 수익률이 뚝 떨어져 버립니다. (비어있는 만큼 NAV에서 떨어트려 버리는 효과가 납니다.)

      만일 n번째 랭킹이 같아져서 (n-1)종목만 선택되든가의 문제에서는,
      method='ordinal' 처럼 그냥 먼저나오는 종목이 n 랭킹을 가져간다는 옵션을 추가해주면 말씀하신 에러는 발생하지 않습니다.

      삭제
    2. 아 그렇군요 !
      저 같은 초보자에게는 이런 꿀팁들이 너무 큰 도움이 되는 것 같아요.
      고급 지식 공유해주셔서 감사드립니다 :-)

      삭제
    3. 페친 신청은 없는거 같은뎅 ㅎㅎ

      삭제
    4. 패캠 수업 중에 수락하셔서 이미 페친입니다 ㅎㅎ

      삭제
  4. 안녕하세요. 항상 좋은 자료를 공유해주셔서 감사합니다.
    위의 코드를 그대로 돌려보니 자꾸 에러가 뜨는데요. 어떠한 이유인지 알 수 있을까요?
    제가 코딩을 몰라서 기초부터 배워야 하는데, 이게 의미하는 바를 몰라서 여쭤봅니다.

    NA's detected: filling NA's with zeros
    Last date in series occurs before beginning of first rebalancing period
    C:/Users/buseon/Desktop/주식/risk parity.py:42: FutureWarning: how in .resample() is deprecated
    the new syntax is .resample(...).max()
    ep = s.resample("M", how="max")
    Traceback (most recent call last):

    File "", line 1, in
    runfile('C:/Users/buseon/Desktop/주식/risk parity.py', wdir='C:/Users/buseon/Desktop/주식')

    File "C:\Users\buseon\Anaconda3\lib\site-packages\spyder\utils\site\sitecustomize.py", line 880, in runfile
    execfile(filename, namespace)

    File "C:\Users\buseon\Anaconda3\lib\site-packages\spyder\utils\site\sitecustomize.py", line 102, in execfile
    exec(compile(f.read(), filename, 'exec'), namespace)

    File "C:/Users/buseon/Desktop/주식/risk parity.py", line 144, in
    port_cumret = ReturnCumulative(portfolio_ret_net)

    File "C:/Users/buseon/Desktop/주식/risk parity.py", line 141, in ReturnCumulative
    print("Total Return: ", round(temp.iloc[-1, :], 4))

    File "C:\Users\buseon\Anaconda3\lib\site-packages\pandas\core\indexing.py", line 1325, in __getitem__
    return self._getitem_tuple(key)

    File "C:\Users\buseon\Anaconda3\lib\site-packages\pandas\core\indexing.py", line 1662, in _getitem_tuple
    self._has_valid_tuple(tup)

    File "C:\Users\buseon\Anaconda3\lib\site-packages\pandas\core\indexing.py", line 189, in _has_valid_tuple
    if not self._has_valid_type(k, i):

    File "C:\Users\buseon\Anaconda3\lib\site-packages\pandas\core\indexing.py", line 1597, in _has_valid_type
    return self._is_valid_integer(key, axis)

    File "C:\Users\buseon\Anaconda3\lib\site-packages\pandas\core\indexing.py", line 1638, in _is_valid_integer
    raise IndexError("single positional indexer is out-of-bounds")

    IndexError: single positional indexer is out-of-bounds

    이라고 뜨는데, 이게 어느 의미일까요?

    답글삭제
    답글
    1. 글쎄요... 디버깅은 직접 보면서 해야 알아서요..

      삭제