[ML4T] S&P500 종목의 백테스트와 주가 수익률 예측을 위한 데이터셋 만들기 - yfinance, talib

목차

    0. 관련글 목록

    (1) [ML4T] Machine Learning for Trading: From Idea to Execution
    (2) [ML4T] 미국 주식 데이터 수집하기 - 주가, 거래량, 재무 데이터
    (3) [ML4T] 선형 팩터 모델: 파마-프렌치(Fama-French) 5팩터 모델, 파마-맥베스(Fama-MacBeth) 회귀분석


    이 글은 퀀트 투자를 위한 머신러닝 딥러닝 알고리즘 트레이딩 2/e 7장의 내용을 바탕으로 작성되었습니다.
    https://github.com/stefan-jansen/machine-learning-for-trading

     

    GitHub - stefan-jansen/machine-learning-for-trading: Code for Machine Learning for Algorithmic Trading, 2nd edition.

    Code for Machine Learning for Algorithmic Trading, 2nd edition. - GitHub - stefan-jansen/machine-learning-for-trading: Code for Machine Learning for Algorithmic Trading, 2nd edition.

    github.com


    1. 들어가며

    이번 글에서는 S&P500 종목의 백테스트와 주가 수익률 예측을 위한 데이터셋을 만드는 과정을 정리해보려고 한다. 데이터셋은 가격과 거래량 정보(OHLCV) 만을 이용하여 만들어 보았다. 재무정보는 데이터셋을 만들때 사용하지 않았는데, 재무정보를 정확하게 사용하기 쉽지 않아보였기 때문이다. 재무정보를 반영하려면 특정 시점에서 해당 데이터가 선견편향(look-ahead bias)을 유발하지는 않는지 확인해보아야 하는데, 어떤 로직으로 데이터를 검증해봐야 할지 아직 정리가 되지 않은 상태이다. 가격과 거래량 정보는 비교적 쉽게 구할 수 있고, 편향 발생의 여지도 재무데이터에 비해서는 적기 때문에 우선 해당 정보만을 이용하여 데이터셋을 만들어 보았다. 나중에는 재무정보도 반영하여 데이터셋을 만들어 보려고 한다.


    2. 필요한 라이브러리 설치 및 임포트

    데이터셋 생성 작업은 코랩에서 진행하였고, 설명도 코랩에서 작업을 한다는 가정하에 해보려고 한다. 우선 주가와 거래량 데이터를 가져오기 위해 [ML4T] 미국 주식 데이터 수집하기 - 주가, 거래량, 재무 데이터에서 설명한 yfinance 패키지를 이용하였고, 각종 기술적 지표를 계산하기 위해 talib 패키지를 사용하였다.

    # install talib
    url = 'https://anaconda.org/conda-forge/libta-lib/0.4.0/download/linux-64/libta-lib-0.4.0-h516909a_0.tar.bz2'
    !curl -L $url | tar xj -C /usr/lib/x86_64-linux-gnu/ lib --strip-components=1
    url = 'https://anaconda.org/conda-forge/ta-lib/0.4.19/download/linux-64/ta-lib-0.4.19-py37ha21ca33_2.tar.bz2'
    !curl -L $url | tar xj -C /usr/local/lib/python3.7/dist-packages/ lib/python3.7/site-packages/talib --strip-components=3
    
    # install yfinance
    !pip install yfinance --upgrade --no-cache-dir
    
    # import packages
    %matplotlib inline
    
    import numpy as np
    import pandas as pd
    
    import matplotlib.pyplot as plt
    import seaborn as sns
    
    from scipy.stats import pearsonr, spearmanr
    from talib import MFI, RSI, BBANDS, MACD
    import yfinance as yf

    yfinance 패키지를 그냥 pip install로 설치하면 제대로 작동하지 않는 기능들이 있어서(ex. yf.download에서 tickers인자에 넣은대로 다운로드가 되지 않음) 위의 커맨드를 사용해서 설치해야 한다.

    윈도우 환경에서 talib을 설치할때 pip install TA-Lib 만 실행하면 에러가 발생하는 경우가 있는데 이럴때는 아래 3가지 방법을 시도해보는 걸 추천한다.

    1. visual studio C++ build tool이 설치되어 있는지 확인
    2. TA-Lib 라이브러리 압축파일을 다운받아 로컬 드라이브에 압축해제 해두었는지 확인하기(talib 깃허브에 설명 있음)
    3. 위의 두 가지를 확인했는데도 안되면 conda install -c conda-forge ta-lib으로 설치

    3. yfianace 패키지로 S&P500 종목 주가 데이터 수집하기

    먼저 위키피디아에서 S&P500 종목 리스트를 가져온다.

    # s&p 500 종목 리스트 가져오기
    sp_url = 'https://en.wikipedia.org/wiki/List_of_S%26P_500_companies' 
    sp500_constituents = pd.read_html(sp_url, header=0)[0] 
    sp500_constituents.info()
    
    <class 'pandas.core.frame.DataFrame'>
    RangeIndex: 505 entries, 0 to 504
    Data columns (total 9 columns):
     #   Column                 Non-Null Count  Dtype 
    ---  ------                 --------------  ----- 
     0   Symbol                 505 non-null    object
     1   Security               505 non-null    object
     2   SEC filings            505 non-null    object
     3   GICS Sector            505 non-null    object
     4   GICS Sub-Industry      505 non-null    object
     5   Headquarters Location  505 non-null    object
     6   Date first added       457 non-null    object
     7   CIK                    505 non-null    int64 
     8   Founded                505 non-null    object
    dtypes: int64(1), object(8)
    memory usage: 35.6+ KB
    sp500_constituents

    리스트를 보면 500개가 아닌 505개의 주식이 있는 것을 볼 수 있는데 5개 종목(Alphabet, Fox Corporation, News Corp, Under Armor, Discovery[각주:1])이 2개의 클래스 주식(Class A, Class C)으로 나누어져 있기 때문이다.

    이제 종목리스트를 yfinance 패키지의 download 함수에 넣어서 2017년 12월 1일~2021년 12월 5일까지 4년 정도의 데이터를 수집한다. download 함수를 이용하면 여러 종목의 주가 데이터를 한번에 받아볼 수 있다.

    # 공백으로 종목을 구분하여 하나의 스트링으로 download 함수의 tickers에 넣어줌
    # yfinance에서 '.' 처리를 못하는듯 함 '-'(dash)로 바꿔서 넣어줘야 함
    tickers = ' '.join(sp500_constituents.Symbol.tolist()).replace('.', '-')
    prices = yf.download(tickers=tickers, 
                         start='2017-12-01', end='2021-12-05',
                         interval='1d',
                         auto_adjust=False # True: adjust all OHLC automatically
                         )

    종목리스트는 공백으로 구분하여 하나의 문자열로 tickers인자에 넣어주면 된다. 또 S&P 500 종목 중 BF.B, BRK.B 티커는 그대로 넣으면 yfinance에서 처리를 못하기 때문에 '.'을 '-'로 바꾼 후 넣어줘야 한다. 종료일자를 12월 5일로 설정해주었는데 12월 3일이 금요일이라 12얼 4일을 종료일로 넣어줘도 된다(end에 원하는 종료일+1일을 넣어줘야 한다; 2021-12-03을 end에 넣으면 2021-12-02까지의 데이터를 가져옴).

    interval에는 '1m', '2m', '5m', '15m', '30m', '60m', '90m', '1h', '1d', '5d', '1wk', '1mo', '3mo'이 가능하며, m은 분단위, h는 시간단위, d는 일단위, wk는 주단위, mo는 월단위를 의미한다.

    auto_adjust는 open, high, low, close를 수정주가로 반영할지 여부이고, 기본값은 False이다. 수정주가는 주식분할, 배당락 등을 고려하여 보정된 주가인데, 데이터를 조회 해보면 주식분할은 기본적으로 반영되어서 제공되는 것 같다. False로 설정하면 adj close 변수가 따로 생성되고, True로 설정하면 close=adj close가 되며 open, high, low, close도 수정주가로 반영된다. 이 글에서는 수정주가를 사용하지 않았지만, 추세 분석시 일반적으로 수정주가를 사용한다고 한다.

    왜 때문인지 모르겠지만 2017-11-30이 모두 Null 값으로 들어와서 해당 로우를 날려주었다.

    prices = prices.dropna(how='all')
    prices

    그런 다음 이후 연산의 편의를 위해 데이터 형태를 조금 바꿔주었다.

    prices = prices.stack().swaplevel().sort_index()
    prices.columns = [col.lower().replace(' ', '_') for col in prices.columns]
    prices.index.names = ['ticker', 'date']
    prices

    나중에 다시 원본 데이터를 불러올 필요가 있을지도 모르니 잘 저장. parquet 타입으로 저장하는 것을 추천한다(압축도 많이 되고 IO 속도도 빠른편).

    prices.to_parquet('sp500_prices_20171201_20211203.parquet')

    4. 기술적 지표 계산

    TA-Lib(talib) 패키지TA-LIB(Technical Anaysis Library)의 Cython 기반 wrapper이다. TA-Lib은 기술적 분석을 수행하기 위한 트레이딩 소프트웨어 개발자들 사이에서 널리 사용된다고 한다. TA-Lib 패키지를 이용하면 ADX, MACD, RSI, 스토캐스틱(Stochastic), 볼린저 밴드(Bollinger Bands) 등 150개 이상의 지표를 계산할 수 있다. 각 지표에 대한 설명은 여기를 참고하면 된다. 이전에 작성했던 글들 중 볼린저 밴드와 MFI 관련된 글들이 있어서 링크를 첨부한다.

    [python] 볼린저 밴드(Bollinger bands) - (1) 볼린저밴드 그리기
    [python] 볼린저 밴드(Bollinger bands) - (2) %b와 밴드폭(BandWidth)
    [python] 볼린저 밴드(Bollinger Bands) - (3) MFI(현금흐름지표)
    [python] 볼린저 밴드를 이용한 트레이딩(1)


    talib을 이용하여 다른 기술적 지표들을 계산하기 전에 종가와 거래량을 기반으로 몇 가지 파생변수를 생성한다. 달러 거래량의 21일 이평선을 계산하여 특정 종목의 거래규모의 추세를 파악할 수 있고, 이를 특정 일자의 투자 유니버스(투자할 종목들)를 결정하는데 사용해볼 수도 있다.

    # 달러 거래량
    prices['dollar_vol'] = prices[['close', 'volume']].prod(axis=1)
    # 달러 거래량의 21일 이평선(한달 동안의 달러 거래량 이동평균)
    prices['dollar_vol_1m'] = (prices.dollar_vol.groupby('ticker')
                               .rolling(window=21, level='date') 
                               # TypeError: __init__() got an unexpected keyword argument 'level' 가 발생하면 level='date' 제외하고 코드 실행
                               .mean()).values
    # 달러 거래량 21일 이평선의 랭킹 계산
    prices['dollar_vol_rank'] = (prices.groupby('date')
                                 .dollar_vol_1m
                                 .rank(ascending=False))

    이제 talib을 이용하여 MFI, RSI, Bollinger Bands, MACD를 계산해보자. ticker를 기준으로 groupby.apply 연산을 수행하면 되는데, RSI처럼 종가만 투입하면 계산할 수 있는 지표도 있고, MFI 처럼 여러 변수를 투입해야 계산할 수 있는 지표도 있다.

    from talib import MFI, RSI, BBANDS, MACD
    
    #### MFI 계산
    prices['mfi'] = prices.groupby(level='ticker').apply(lambda x: MFI(x['high'], 
                                                                       x['low'], 
                                                                       x['close'], 
                                                                       x['volume'])).droplevel(0)
    #### RSI 계산
    prices['rsi'] = prices.groupby(level='ticker').close.apply(RSI)
    
    #### 볼린저밴드 상한과 하한 계산
    def compute_bb(close):
        high, mid, low = BBANDS(close, timeperiod=20)
        return pd.DataFrame({'bb_high': high, 'bb_low': low}, index=close.index)
    
    prices = (prices.join(prices
                          .groupby(level='ticker')
                          .close
                          .apply(compute_bb)))
    prices['bb_high'] = prices.bb_high.sub(prices.close).div(prices.bb_high).apply(np.log1p)
    prices['bb_low'] = prices.close.sub(prices.bb_low).div(prices.close).apply(np.log1p)
    
    #### MACD 계산
    def compute_macd(close):
        macd = MACD(close)[0]
        return (macd - np.mean(macd))/np.std(macd)
    
    prices['macd'] = (prices
                      .groupby('ticker', group_keys=False)
                      .close
                      .apply(compute_macd))

    그래프를 그려서 각 변수의 분포 확인해보자.

    ax = sns.distplot(prices.mfi.dropna())
    ax.axvline(30, ls='--', lw=1, c='k')
    ax.axvline(70, ls='--', lw=1, c='k')
    ax.set_title('MFI Distribution with Signal Threshold')
    plt.tight_layout();

    ax = sns.distplot(prices.rsi.dropna())
    ax.axvline(30, ls='--', lw=1, c='k')
    ax.axvline(70, ls='--', lw=1, c='k')
    ax.set_title('RSI Distribution with Signal Threshold')
    plt.tight_layout();

    fig, axes = plt.subplots(ncols=2, figsize=(15, 5))
    sns.distplot(prices.loc[:, 'bb_low'].dropna(), ax=axes[0])
    sns.distplot(prices.loc[:, 'bb_high'].dropna(), ax=axes[1])
    plt.tight_layout();

    sns.distplot(prices.macd.dropna());


    5. lag 수익률(Lagged Returns) 계산

    lag 수익률은(Lagged Returns)은 t 영업일 전 시점에서 계산된 최근 n 영업일 주가수익률이다. lag 수익률은 다음과 같이 계산할 수 있다.

    (1) 최근 n 영업일의 주가수익률 계산
    (2) 극단적인 수익률 값 제거
    (3) 수익률 일할 계산(기하평균)
    (4) 수익률 변수를 lag(t 영업일) 만큼 현재 시점으로 shift
    ndays = [1, 5, 10, 21, 42, 63]
    q = 0.0001
    for n in ndays:
        prices[f'return_{n}d'] = (prices.groupby(level='ticker').close
                                    .pct_change(n)
                                    .pipe(lambda x: x.clip(lower=x.quantile(q),
                                                           upper=x.quantile(1 - q)))
                                    .add(1).pow(1/n).sub(1)
                                    )
    prices.loc[prices.index.get_level_values('ticker').isin(['AAPL']), 
               ['close']+[col for col in prices.columns if 'return_' in col]].head(10)

    lags = [1, 2, 3, 4, 5]
    for lag in lags:
        for n in ndays:
            prices[f'return_{n}d_lag{lag}'] = (prices.groupby(level='ticker')[f'return_{n}d'].shift(lag))
    prices.loc[prices.index.get_level_values('ticker').isin(['AAPL']), 
               ['close']+[col for col in prices.columns if '_lag' in col]].head(20)

    예를 들어, 애플(AAPL)의 2017년 12월 11일 시점에서 return_5d_lag1은 1영업일 전인 2017년 12월 8일에 계산된 최근 5일 평균 수익률이다.


    6. 섹터, 시간 더미변수 생성

    섹터 정보와 시간 정보를 반영하기 위한 더미변수를 만들어 보자. 위키피디아 S&P 500 리스트 정보를 다시 불러와서 섹터정보를 결합하고 date에서 year와 month 정보를 추출한 후, pd.get_dummies 함수를 통해 더미변수를 생성한다. drop_first=True로 설정하면 더미코딩, False로 설정하면 원핫인코딩이다.

    # s&p 500 종목 리스트 가져오기
    sp_url = 'https://en.wikipedia.org/wiki/List_of_S%26P_500_companies' 
    sp500_constituents = pd.read_html(sp_url, header=0)[0] 
    sector = sp500_constituents[['Symbol', 'GICS Sector']]
    
    sector['GICS Sector'].value_counts()
    
    Information Technology    75
    Industrials               73
    Financials                65
    Health Care               64
    Consumer Discretionary    63
    Consumer Staples          32
    Real Estate               29
    Materials                 28
    Utilities                 28
    Communication Services    27
    Energy                    21
    Name: GICS Sector, dtype: int64
    sector.columns = ['ticker', 'sector']
    sector = sector.set_index('ticker')
    prices = prices.join(sector)
    prices = prices.assign(sector = prices.sector.str.replace(' ', '_'))
    
    prices['year'] = prices.index.get_level_values('date').year
    prices['month'] = prices.index.get_level_values('date').month
    
    prices = pd.get_dummies(prices,
                            columns=['year', 'month', 'sector'],
                            prefix=['year', 'month', ''],
                            prefix_sep=['_', '_', ''],
                            drop_first=True)

    7. 타겟변수 생성

    마지막으로 타겟변수를 생성한다. 타겟변수는 특정시점에서 t 영업일 후의 수익률(Forward Returns)로 설정할 수 있다. lag 수익률 변수를 생성할 때 계산해둔 n 영업일 동안의 일평균 수익률을 1일 후, 5일 후, 10일 후, 21일 후 등의 미래시점으로 이동시켜 타겟변수를 계산할 수 있고, 얼마나 먼 미래까지 수익률을 계산할지는 해당 타겟변수로 학습시킨 모형의 성능을 평가하여 결정하거나 추구하는 트레이딩의 방향성에 따라 달라 질 수 있을 것 같다.

    for t in [1, 5, 10, 21]:
        prices[f'target_{t}d'] = prices.groupby(level='ticker')[f'return_{t}d'].shift(-t)
    prices[['close', 'return_1d', 'return_1d_lag1', 'target_1d']].head()

    마지막으로 만들어진 데이터셋을 저장하면 끝.

    prices.to_parquet('sp500_modeling_data.parquet')

    8. 글을 마치며

    글에서 사용한 전체코드는 다음과 같다.

    #### install talib
    url = 'https://anaconda.org/conda-forge/libta-lib/0.4.0/download/linux-64/libta-lib-0.4.0-h516909a_0.tar.bz2'
    !curl -L $url | tar xj -C /usr/lib/x86_64-linux-gnu/ lib --strip-components=1
    url = 'https://anaconda.org/conda-forge/ta-lib/0.4.19/download/linux-64/ta-lib-0.4.19-py37ha21ca33_2.tar.bz2'
    !curl -L $url | tar xj -C /usr/local/lib/python3.7/dist-packages/ lib/python3.7/site-packages/talib --strip-components=3
    
    !pip install yfinance --upgrade --no-cache-dir
    
    %matplotlib inline
    
    import numpy as np
    import pandas as pd
    
    import matplotlib.pyplot as plt
    import seaborn as sns
    
    from scipy.stats import pearsonr, spearmanr
    from talib import RSI, BBANDS, MACD, ATR
    import yfinance as yf
    
    #### s&p 500 종목 리스트 가져오기
    sp_url = 'https://en.wikipedia.org/wiki/List_of_S%26P_500_companies' 
    sp500_constituents = pd.read_html(sp_url, header=0)[0] 
    
    #### yfinance 패키지로 s&p 500 종목 주가 데이터 가져오기
    tickers = ' '.join(sp500_constituents.Symbol.tolist()).replace('.', '-')
    prices = yf.download(tickers=tickers, start='2017-12-01', end='2021-12-05')
    
    prices = prices.dropna(how='all')
    prices = prices.stack().swaplevel().sort_index()
    prices.columns = [col.lower().replace(' ', '_') for col in prices.columns]
    prices.index.names = ['ticker', 'date']
    
    # 데이터 저장
    prices.to_parquet('sp500_prices_20171201_20211203.parquet')
    
    # 저장한 데이터 불러오기
    prices = pd.read_parquet('sp500_prices_20171201_20211203.parquet')
    
    #### 기술적 지표 계산
    # 달러 거래량
    prices['dollar_vol'] = prices[['close', 'volume']].prod(axis=1)
    # 달러 거래량의 21일 이평선(한달 동안의 달러 거래량 이동평균)
    prices['dollar_vol_1m'] = (prices.dollar_vol.groupby('ticker')
                               .rolling(window=21, level='date') 
                               # TypeError: __init__() got an unexpected keyword argument 'level' 가 발생하면 level='date' 제외하고 코드 실행
                               .mean()).values
    # 달러 거래량 21일 이평선의 랭킹 계산
    prices['dollar_vol_rank'] = (prices.groupby('date')
                                 .dollar_vol_1m
                                 .rank(ascending=False))
    
    from talib import MFI, RSI, BBANDS, MACD
    # MFI 계산
    prices['mfi'] = prices.groupby(level='ticker').apply(lambda x: MFI(x['high'], 
                                                                       x['low'], 
                                                                       x['close'], 
                                                                       x['volume'])).droplevel(0)
    # RSI 계산
    prices['rsi'] = prices.groupby(level='ticker').close.apply(RSI)
    
    # 볼린저밴드 상한, 하한 계산
    def compute_bb(close):
        high, mid, low = BBANDS(close, timeperiod=20)
        return pd.DataFrame({'bb_high': high, 'bb_low': low}, index=close.index)
    
    prices = (prices.join(prices
                          .groupby(level='ticker')
                          .close
                          .apply(compute_bb)))
    prices['bb_high'] = prices.bb_high.sub(prices.close).div(prices.bb_high).apply(np.log1p)
    prices['bb_low'] = prices.close.sub(prices.bb_low).div(prices.close).apply(np.log1p)
    
    # MACD 계산
    def compute_macd(close):
        macd = MACD(close)[0]
        return (macd - np.mean(macd))/np.std(macd)
    
    prices['macd'] = (prices
                      .groupby('ticker', group_keys=False)
                      .close
                      .apply(compute_macd))
    
    #### lag 수익률 계산
    ndays = [1, 5, 10, 21, 42, 63]
    q = 0.0001
    for n in ndays:
        prices[f'return_{n}d'] = (prices.groupby(level='ticker').close
                                    .pct_change(n)
                                    .pipe(lambda x: x.clip(lower=x.quantile(q),
                                                           upper=x.quantile(1 - q)))
                                    .add(1)
                                    .pow(1 / n)
                                    .sub(1)
                                    )
        
    lags = [1, 2, 3, 4, 5]
    for lag in lags:
        for n in ndays:
            prices[f'return_{n}d_lag{lag}'] = (prices.groupby(level='ticker')[f'return_{n}d'].shift(lag)) 
    
    #### 섹터, 시간 더미변수 생성
    # s&p 500 종목 리스트 가져오기
    sp_url = 'https://en.wikipedia.org/wiki/List_of_S%26P_500_companies' 
    sp500_constituents = pd.read_html(sp_url, header=0)[0] 
    sector = sp500_constituents[['Symbol', 'GICS Sector']]
    
    sector.columns = ['ticker', 'sector']
    sector = sector.set_index('ticker')
    prices = prices.join(sector)
    prices = prices.assign(sector = prices.sector.str.replace(' ', '_'))
    
    prices['year'] = prices.index.get_level_values('date').year
    prices['month'] = prices.index.get_level_values('date').month
    
    prices = pd.get_dummies(prices,
                            columns=['year', 'month', 'sector'],
                            prefix=['year', 'month', ''],
                            prefix_sep=['_', '_', ''],
                            drop_first=True)
    
    #### 타겟변수 생성
    for t in [1, 5, 10, 21]:
        prices[f'target_{t}d'] = prices.groupby(level='ticker')[f'return_{t}d'].shift(-t)
    
    #### 데이터 저장
    prices.to_parquet('sp500_modeling_data.parquet')

    이번 글에서는 S&P 500 종목으로 주가데이터를 가져와서 백테스팅과 주가수익률 예측을 위한 데이터셋을 만들어 보았다. 이어지는 글들에서는 (1) 주가수익률 예측 모형 만들기, (2) 전략 백테스팅 해보기 등의 내용을 다루게 될 것 같다. 

    1. 리스트에서 Discovery만 Series A, Series C로 표기됨 [본문으로]