참고: 퀀트 투자를 위한 머신러닝, 딥러닝 알고리즘 트레이딩
집라인이 들어간 모든 파일은 제대로 작동하지 않지만 그외의 것들은 작동하는 것도 있어서 정리해 놓기로 하였다.
ML4T의 모든 모듈의 종합 목적은 과거 데이터에서 증거를 수집하는 것. 실제 시장에서 이 것이 제대로 되는가를 결정하는 데에 도움을 받는 것이 목적.
다음의 단계를 따르게 된다.
1. 기본, 대체 데이터의 준비
2. 팩터 및 특성 공학
3. 모델을 설계, 튜닝, 평가해서 신호 생성
4. 신호에 대한 규칙을 적용해, 신호에 따른 거래 결정
5. 포트폴리오 측면에서 개별 포지션의 크기 결정(포트폴리오 최적화)
6. 과거 시장 데이터를 이용해 트레이딩 시뮬레이션
7. 최종 수행 평가
백테스트의 성과는 새로운 입력 데이터에 잘 맞아야 되고, 미래 시장 성과를 잘 나타내야 한다.
백테스트는 어디까지나 과거 데이터를 기반으로하기 때문에 시장의 일반적인 불확실성 외에도 구현의 일부 측면은 결과를 편향시킬 수 있으며 표본 내에서만 성립하는 것을 표본 외에서도 성립한다고 오인할 리스크를 증가시키기도 한다. 특히 계산 능력이 늘고 데이터 세트가 커지며 표본의 잡음이 많을 때, 알고리즘이 복잡해짐에 따라 더욱 리스크가 증가한다.
이 책에서 추천한 Advances in Financial machine Learning도 바로 주문했다. 가장 중요한 포인트가, 반복횟수가 늘어남에 따라 실제로 샤프 비율이 0이더라도 이를 만들어 낼 수 있다는 점을 알려준다고 한다. 이를 위해 deflate sharpe ratio를 쓰라고.
데이터를 준비함에 따라 고려해야 되는 것들이 있다.
1. 선견 편향(look-ahead bias):
과거 정보를 이용해 규칙을 만들때, '정말로' 그 시점에서 구할 수 있는 데이터만 이용해야 한다. 주식 분할 및 합병 등의 경우 등에 대해, 조정 EPS가 실제로 공개되었을 때를 고려해 동기화 되는 등의 작업이 필요하다.
이 것은 각 데이터의 타임스탬프를 제대로 검증하는 것으로 해결할 수 있다. 해당 정보를 정확하게 모르는 경우 지연에 대한 보수적인 가정을 해야 한다.
생존 편향(survivorship bias):
시간이 지남에 따라 사라진 자산을 생략하면 발생하는 문제. 이 자산들을 빼버리면, 그 자체로 인해 백테스팅 결과가 개선된 것 처럼 보일 수 있다. 백테스팅을 할 때 해당 자산들이 모두 포함되는지를 확인하면 된다. 2번은 1번이 제대로 지켜지는지 확인하는 것이기도 하다.
특이값 제어:
보통 데이터 전처리에는 특이값을 제거한다. 문제는 정말로 시장을 대표하는 특이값과 노이즈 특이값을 구분해야 된다는 것이다. 특히 fat tail분포로 제시됨에도 정규분포로 모델을 세우면 이런 극단값이 무시된다.
이건 특이값을 직접 분석하고, 현실에 맞게 조정하는 작업을 더하는 것외에는 방법이 없다.
표본 기간:
내가 보고 싶은 미래 시나리오를 대표할 수 있는 기간을 설정해야 한다. 이에 대해, 중요한 시장 현상을 포함하는 기간을 사용하거나, 관련 시장이 반영되도록 데이터를 생성해야 한다.
시뮬레이션 자체에 대해 고려해야 되는 것은 다음과 같다.
1. mark to market(시장 가격, 계정 또는 인출을 정확하게 반영하는 시가 평가)의 실패
매니저는 시가 평가된 포지션의 가치를 정기적, 그리고 실시간으로 추적하고 보고해야 한다. VaR, 혹은 소르티노 비율등의 척돌르 계산하는 작업이 있을 수 있다.
거래 비용
시장에서는 특히 거래량이 많다면 절대 원하는 가격에 거래할 수 없다. 이렇게 덜 유리한 조건을 제대로 반영하지 않는다면 편향된 결과가 나올 것이다. 브로커 수수료 뿐 아니라, 공매도를 실시할 때 받아줄 상대가 없을 때의 비용, 유동성이 적은 자산의 시장 충격등을 과소 평가함으로써 이런 문제가 발생할 수 있다.
이것이 제대로 반영되지 않으면 비현실적으로 과도한 회전율을 만드는 불안정한 신호를 사용하게 될 수도 있다.
의사결정 타이밍
예를 들어, 거래가 다음 시장의 개장에만 가능함에도 종가로 거래를 했다고 가정하는 식. 신호의 도착과 거래 실행을 신중하게 평가하지 않으면, 특히 수정종가를 이용한 거래 신호를 사용하는 경우 문제가 발생할 수 있다.
백테스트의 문제는, 가짜 패턴의 발견이다. 5%유의수준을 준다 하면, 어쨋건 100번중 5번은 가짜 패턴을 발견할 수도 있다는 것이다. 과대적합이 쉽게 발생할 수 있음을 감안하면, 이는 별로 좋지 않다. 따라서 이 패턴은, 제대로 된 평가가 가능할 수 있는 정도의 시행횟수가 보고 됐을 때만 가치가 있다. 대부분의 학술 연구에서 이것이 제대로 시행되지 않는다고 한다.
이에 대한 해결책은, 제대로 된 이론적 배경이 있어 정당화가 될 수 있는 것에 우선순위를 정하는 것을 포함한다.
이 뿐 아니라 백테스트를 언제까지 실시하느냐(=언제 정지)도 중요한 문젠데, 이거는 최적 정지 이론을 따른다고 한다. (n/e 후보를 거부한 다음 이를 능가하는 첫 후보를 고르는 것)
백테스트 접근법에는 벡터와 접근법과 이벤트 기반 접근법이 있는데, 두 접근법은 서로 다르다.
벡터 접근법은 목표 포지션 크기(신호 벡터)*투자 기간 수익률 벡터 로 기간 성과를 계산한다.
매우 간단하고 신속한(back-of-the-envelope) 평가가 가능하지만, 벡테스트 엔진의 중요 특징을 놓친다는 단점이 있다.
1. 타임 스탬프를 수동으로 정렬해야 하며 선견 편향에 대한 안전 장치가 없다.
2. 포지션 조정도 없으며, 기타 시장 현실을 반영하는 프로세스도 없고, 회계시스템의 명시적 표현또한 없다.
3. 손절매, 리스크 관리 규칙 등을 시뮬레이션하기가 어렵고, 사후 계산 외의 성과 측정 방법도 없다.
여기서 이벤트 중심 백테스트가 시작되는데, 환경의 시간 차원을 더 명시적으로 시뮬레이팅하며, 더 많은 구조를 부과한다. 타임스탬프를 이용해 선견 편향 및 구현 오류를 조금 더 방지할 수 있다.
백테스팅은 주로 사이킷런 같은 모듈로 모델을 설계하고, 퀀토피안이나 퀀트 커낵트등의 플랫폼에서 백테스팅을 실시할 수 있다.
다행히 backtrader는 제대로 작동하므로, 이에 대해 별도의 페이지를 만들어 정리해 놓을 것.
이에 대한 코드는 상당히 복잡하므로, 전체 코드를 따라가면서 정리할 예정이다.
일단 요약하면 리지 회귀를 이용해 일일 수익률을 예측하고, 이를 백트레이더에서 사용해 볼 것이다.
우선 수수료에 대한 것이다.
class FixedCommisionScheme(bt.CommInfoBase):
params = (
('commission', .02),
('stocklike', True),
('commtype', bt.CommInfoBase.COMM_FIXED),
)
def _getcommission(self, size, price, pseudoexec):
return abs(size) * self.p.commission
파라미터를 정의해 주었다. 커미션은 주당 0.02$의 고정 값이다.
제일 흥미로운 전략 부분이다.
class MLStrategy(bt.Strategy):
params = (('n_positions', 10),
('min_positions', 5),
('verbose', False),
('log_file', 'backtest.csv'))
def log(self, txt, dt=None):
""" Logger for the strategy"""
dt = dt or self.datas[0].datetime.datetime(0)
with Path(self.p.log_file).open('a') as f:
log_writer = csv.writer(f)
log_writer.writerow([dt.isoformat()] + txt.split(','))
def notify_order(self, order):
if order.status in [order.Submitted, order.Accepted]:
return
# Check if an order has been completed
# broker could reject order if not enough cash
if self.p.verbose:
if order.status in [order.Completed]:
p = order.executed.price
if order.isbuy():
self.log(f'{order.data._name},BUY executed,{p:.2f}')
elif order.issell():
self.log(f'{order.data._name},SELL executed,{p:.2f}')
elif order.status in [order.Canceled, order.Margin, order.Rejected]:
self.log(f'{order.data._name},Order Canceled/Margin/Rejected')
# bt calls prenext instead of next unless
# all datafeeds have current values
# => call next to avoid duplicating logic
def prenext(self):
self.next()
def next(self):
today = self.datas[0].datetime.date()
# if today.weekday() not in [0, 3]: # only trade on Mondays;
# return
positions = [d._name for d, pos in self.getpositions().items() if pos]
up, down = {}, {}
missing = not_missing = 0
for data in self.datas:
if data.datetime.date() == today:
if data.predicted[0] > 0:
up[data._name] = data.predicted[0]
elif data.predicted[0] < 0:
down[data._name] = data.predicted[0]
# sort dictionaries ascending/descending by value
# returns list of tuples
shorts = sorted(down, key=down.get)[:self.p.n_positions]
longs = sorted(up, key=up.get, reverse=True)[:self.p.n_positions]
n_shorts, n_longs = len(shorts), len(longs)
# only take positions if at least min_n longs and shorts
if n_shorts < self.p.min_positions or n_longs < self.p.min_positions:
longs, shorts = [], []
for ticker in positions:
if ticker not in longs + shorts:
self.order_target_percent(data=ticker, target=0)
self.log(f'{ticker},CLOSING ORDER CREATED')
short_target = -1 / max(self.p.n_positions, n_shorts)
long_target = 1 / max(self.p.n_positions, n_longs)
for ticker in shorts:
self.order_target_percent(data=ticker, target=short_target)
self.log('{ticker},SHORT ORDER CREATED')
for ticker in longs:
self.order_target_percent(data=ticker, target=long_target)
self.log('{ticker},LONG ORDER CREATED')
쭉 따라가보자.
param은 사용할 파라미터다.
포지션의수, 최소 포지션 수, 로깅 여부(verbose)이다.
log에는, dt로 날짜를 주지 않으면 가장 마지막 날짜를 사용한다.
log은 csv파일로 저장
notify_order는 verbose일 때만.
next: 가장 핵심이 되는 알고지즘. min_position이 있는한, 예측 수익률중 가장 높은 양의 예측, 가장 높은 음의 예측을 가진 것들 각각을 long/short
1. 우선 기존 포지션들을 들고온다.
2. 주가 데이터에 대해 iterate하면서, 각 주식 예측 수익률이 양인지 음인지에 따라 주식 이름과 예측 수익률을 up, down dictionary에 저장.
3. up, down에 대해 오름차순, 내림차순으로 정렬한뒤, n_position개 만큼의 데이터 추출
4. 이 때, 만약에 long및 short중 하나라도 min_position보다 적다면(즉 전체 양의 수익률이라던지?), 포지션을 갖지 않는다.(빈 배열로 초기화)
5. 기존 포지션을 확인해서, 새로운 long 혹은 short 목록에 안 들어 있다면 포지션을 청산한다.
6. 그 외에는 목록에 맞도록 포지션 생성.
order_target_percent는 포트폴리오에서 차지하는 비중이 이렇게 되도록 하는 주문. 그래서 코드를 보면, short는 음의 목표 지수를 갖는다.
이후 브로커 세팅을 하고, input 데이터를 넣어준다.
데이터를 넣는 코드도 들여볼만하다.
idx = pd.IndexSlice
data = pd.read_hdf('00_data/backtest.h5', 'data').sort_index()
tickers = data.index.get_level_values(0).unique()
for ticker in tickers:
df = data.loc[idx[ticker, :], :].droplevel('ticker', axis=0)
df.index.name = 'datetime'
bt_data = SignalData(dataname=df)
cerebro.adddata(bt_data, name=ticker)
우선 ticker는 데이터베이스에서 unique를 이용해서 뽑아낸다.
이후 데이터 프레임에서 데이터를 뽑아낸다.
이걸, SignalData안에 넣어 처리해 시그널들이 들어 잇는 dictionary를 만들어 냈다.
그리고 이걸 더해준다. SignalData object가 만들어져서, 이건 아마 그냥 그대로 사용할 듯..?
cerebro.addanalyzer(bt.analzers.PyFolio, _name='pyfolio')처럼 analyzer를 선택해서 넣어줄 수 있다.
보면 results=cerebro.run()을 이용해서 run의 결과를 저장해준다.
이렇게 받은 results에 대해
pyfolio_analyzer = results[0].analyzers.getbyname('pyfolio')
returns, positions, transactions, gross_lev = pyfolio_analyzer.get_pf_items()
를 이용하면 간단하게 pyfolio를 이용한 분석이 가능하다.
벤치마크 데이터는 다음처럼 가져온다.
benchmark = web.DataReader('SP500', 'fred', '2014', '2018').squeeze()
benchmark = benchmark.pct_change().tz_localize('UTC')
위에서 했던 것처럼, pyfolio로 분석을 실행했으면
pf.create_full_tear_sheet도 사용이 가능하다.