레이블이 backtesting인 게시물을 표시합니다. 모든 게시물 표시
레이블이 backtesting인 게시물을 표시합니다. 모든 게시물 표시

2022년 1월 9일 일요일

backtesting.py 나만의 전략 my Strategy 만들어보기

backtesting 이란? backtesting.py 이해와 분석

backtesting.py custom data 적용과  buy() 메소드 사용과 구매시점 이해

backtesting.py 나만의 전략 my Strategy 만들어보기

1. 익숙한 전략 만들어 보기

1.1 변동성 돌파 전략

변동성 돌파 전략은 일일 단위로 일정 수준 이상의 범위를 뛰어넘는 강한 상승세를 돌파 신호로 상승하는 추세를 따라가며 일 단위로 빠르게 수익을 실현하는 단기매매 전략입니다.

변동성 돌파에 대해서는 아래 링크로 부터 자세한 내용을 공부 해보시기 바랍니다.

https://tvextbot.github.io/post/indicator_vbi/

① 전날의 일봉 기준 range(= 전일 고가 – 전일 저가)를 계산합니다.

② 당일 장중 가격이 당일시가 + (전일 range 값 * K)을 넘을 경우 매수 합니다. (K = 노이즈비율)

③ 익일 시가 기준으로 지정가 매도를 합니다.


1.2 변동성 돌파 전략 코드 구현해보기

변동성 돌파 전략의 핵심은 변동성의 돌파되는 시점에 구매를 하는것인데 backtesting.py에서 buy 신호를 내면 다음날 구매가 일어나거나 오늘 종가로 구매가 일어나게 됩니다.(옵션 조절가능) 또한 판매하는 시점도 다음날에 매도를 하더라도 종가나 그 다음날의 시가로 매도가 되기 때문에 backtesting.py에서 변동성 돌파 전략은 어울리지 않습니다.

그러나 여기에서는 적절한 이론을 실제 구현하는것에 의미를 뒤기 때문에 그런부분들은 중요하게 보지 말고 구현하는것에 촛점을 두었으면 합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from backtesting import Backtest, Strategy
from backtesting.lib import crossover
from backtesting.test import SMA, GOOG

K = 0.6
class SmaCross(Strategy):
	
	def init(self):
		self.does_have_stock = False
		
	def next(self):
		if self.does_have_stock == True:
			# 전일 산 주식이 있으면 팔다
			self.position.close()
			self.does_have_stock = False
		else:
			# 전일 고가-전일 저가
			delta_price = self.data.High[-2] - self.data.Low[-2]
			if delta_price < 0 :
				delta_price *= -1
			# 당일 시가 + ( 전일 range 값 * K ) < 당일 고가 : 당일 고가가 변동성 보다 높아짐 구매한다
			if ( self.data.Open[-1] + (delta_price * K)) < self.data.High[-1]:
				self.buy()
				self.does_have_stock = True


bt = Backtest(GOOG, SmaCross, commission=0.0, exclusive_orders=True)
stats = bt.run()

bt.plot()

print(stats)
print(stats['_trades'])

전날의 변동성 부분은 [-2] 인덱스를 사용하여 계산합니다.

라인 18 참고 delta_price = self.data.High[-2] - self.data.Low[-2]

그럴일은 없지만 라인 19/20에서 음수가 발생하면 곱해서 양수를 만들어 주도록 합니다.

② 당일 장중 가격이 당일시가 + (전일 range 값 * K)을 넘을 경우 매수 합니다. (K = 노이즈비율)

이 부분은 라인 22에서 구현하였습니다. 변동성을 돌파하면 buy()신호를 발생시키고 self.does_have_stock = True로 만들면 다음날 self.position.close()에 의해 자동으로 팔게 됩니다.


마지막 수익률은 147%가 나왔습니다.


1.3 변동성 돌파 + 이동 평균선 전략 합치기

저는 이익보다는 안전 전략을 세워봤습니다. 그래서 이동 평균선이 만나는 지점에 사는것이 아니라 mark를 해놓고 변동성 돌파를 하게되면 구매하는 전략을 세웠습니다. 시너지가 있을지 확인해보겠습니다.


1.4 변동성 돌파 + 이동 평균선 전략 코드 구현해보기

기존에 넣었던  crossover 조건에 나오는 변수는 self.can_buy 라는 변수인데 해당값이 True가 될때만 구매가 가능하도록 구현하였습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
from backtesting import Backtest, Strategy
from backtesting.lib import crossover
from backtesting.test import SMA, GOOG

K = 0.6
class SmaCross(Strategy):
	
	def init(self):
		price = self.data.Close
		self.does_have_stock = False
		self.ma1 = self.I(SMA, price, 10)
		self.ma2 = self.I(SMA, price, 20)
		self.can_buy = False
	def next(self):
		if crossover(self.ma1, self.ma2):
			self.can_buy = True
		elif crossover(self.ma2, self.ma1):
			self.can_buy = False
		if self.does_have_stock == True:
			# 전일 산 주식이 있으면 팔다
			self.position.close()
			self.does_have_stock = False
		elif self.can_buy:
			# 전일 고가-전일 저가
			delta_price = self.data.High[-2] - self.data.Low[-2]
			if delta_price < 0 :
				delta_price *= -1
			# 당일 시가 + ( 전일 range 값 * K ) < 당일 고가 : 당일 고가가 변동성 보다 높아짐 구매한다
			if ( self.data.Open[-1] + (delta_price * K)) < self.data.High[-1]:
				self.buy()
				self.does_have_stock = True


bt = Backtest(GOOG, SmaCross, commission=0.0, exclusive_orders=True)
stats = bt.run()

bt.plot()

print(stats)
print(stats['_trades'])

Final이 186%가 나왔습니다. 이전 보다 좋은 결과이긴하지만 이동 평균선 전략 단독 보다도 떨어지는 수치입니다.


이동 평균선 단독 전략은 아래와 같습니다.

무려 Final이 749% 입니다.


이때의 소스입니다.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from backtesting import Backtest, Strategy
from backtesting.lib import crossover
from backtesting.test import SMA, GOOG

class SmaCross(Strategy):
	
	def init(self):
		price = self.data.Close
		self.ma1 = self.I(SMA, price, 10)
		self.ma2 = self.I(SMA, price, 20)
	def next(self):
		if crossover(self.ma1, self.ma2):
			self.buy()
		elif crossover(self.ma2, self.ma1):
			self.position.close()

bt = Backtest(GOOG, SmaCross, commission=0.0, exclusive_orders=True)
stats = bt.run()

bt.plot()

print(stats)
print(stats['_trades'])


2. 한계

변동성 돌파라는 단순한 전략이라는 개념을 구현해보았습나다만, 사실 제대로 구현되지는 않았습니다. backtesting이 과거의 자료를 가지고 구현을 하다보니 딱 맞지 않습니다. 매수 하는 타이밍이 안맞고, 판매하는 시점도 맞지 않습니다.

결론적으로 말을 하자면 backtesting은 빈번하게 거래하는 부분하고는 잘 맞지 않습니다.

여기 자료를 보면 data가 한개만 들어감을 알 수 있습니다. 종목이 하나밖에 안들어간다는 의미입니다. 이건 사고자 하는 주식이나 코인에 대한 데이터가 되고 만약 참조하는 데이터가 여러개라면 어떻게 처리해야할까요? data에 여러개의 데이터를 붙이면 됩니다. 물론 Open, High 이런 label이 중복 안되도록 해서 진행하면 됩니다. 

여러가지 예측 기법(회귀 기법, 머신러닝, 인공지능 등)을 통해서 새로운 결과를 만들어서 제공하는것입니다. 즉 여기에는 전략이란 필요하지 않습니다. 예를들어 buy 컬럼을 하나 만들어 그곳에 0.5보다 큰 값이면 구매하도록 만드는 방식입니다. 이것은 다음에 예를 들어 보도록 하겠습니다.

머신러닝을 이용한 방법은 아래 링크에 있습니다만 좀 더 이해가 필요합니다.

https://kernc.github.io/backtesting.py/doc/examples/Trading%20with%20Machine%20Learning.html


3. 오류

3.1 너무 많은 데이터로 작업시

진행하다가 data가 많아서 아래와 같은 경우가 있을 수 있습니다.
원인은 "Data contains too many candlesticks to plot" 데이터가 너무 많아서 캔들 표시하는데 downsapling을 시작하는데 그러다가 오류가 발생하는건입니다. 
이건 정확히 분석해서 해결 처리할 수도 있지만, 분석하기에는 시간도 오래걸리니.... 데이터를 줄이던가 Backtest.plot(resample=False) 부분의 resample=False를 넣도록 합니다.
캔들만드는데 시간이 오래걸리는 문제가 있긴하지만 동작은 됩니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
C:\Users\USER\AppData\Local\Programs\Python\Python38\lib\site-packages\backtesting\_plotting.py:122: UserWarning: Data contains too many candlesticks to plot; downsampling to '10T'. See `Backtest.plot(resample=...)`
  warnings.warn(f"Data contains too many candlesticks to plot; downsampling to {freq!r}. "
Traceback (most recent call last):
  File "C:\Users\USER\AppData\Local\Programs\Python\Python38\lib\site-packages\pandas\core\groupby\generic.py", line 260, in aggregate
    return self._python_agg_general(
  File "C:\Users\USER\AppData\Local\Programs\Python\Python38\lib\site-packages\pandas\core\groupby\groupby.py", line 1083, in _python_agg_general
    result, counts = self.grouper.agg_series(obj, f)
  File "C:\Users\USER\AppData\Local\Programs\Python\Python38\lib\site-packages\pandas\core\groupby\ops.py", line 897, in agg_series
    return grouper.get_result()
  File "pandas\_libs\reduction.pyx", line 162, in pandas._libs.reduction.SeriesBinGrouper.get_result
  File "pandas\_libs\reduction.pyx", line 74, in pandas._libs.reduction._BaseGrouper._apply_to_group
  File "C:\Users\USER\AppData\Local\Programs\Python\Python38\lib\site-packages\pandas\core\groupby\groupby.py", line 1060, in <lambda>
    f = lambda x: func(x, *args, **kwargs)
  File "C:\Users\USER\AppData\Local\Programs\Python\Python38\lib\site-packages\backtesting\_plotting.py", line 147, in f
    mean_time = int(bars.loc[s.index].view(int).mean())
  File "C:\Users\USER\AppData\Local\Programs\Python\Python38\lib\site-packages\pandas\core\series.py", line 667, in view
    return self._constructor(
  File "C:\Users\USER\AppData\Local\Programs\Python\Python38\lib\site-packages\pandas\core\series.py", line 313, in __init__
    raise ValueError(
ValueError: Length of passed values is 2, index implies 1.

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "bt_sample.py", line 30, in <module>
    bt.plot()
  File "C:\Users\USER\AppData\Local\Programs\Python\Python38\lib\site-packages\backtesting\backtesting.py", line 1592, in plot
    return plot(
  File "C:\Users\USER\AppData\Local\Programs\Python\Python38\lib\site-packages\backtesting\_plotting.py", line 203, in plot
    df, indicators, equity_data, trades = _maybe_resample_data(
  File "C:\Users\USER\AppData\Local\Programs\Python\Python38\lib\site-packages\backtesting\_plotting.py", line 153, in _maybe_resample_data
    trades = trades.assign(count=1).resample(freq, on='ExitTime', label='right').agg(dict(
  File "C:\Users\USER\AppData\Local\Programs\Python\Python38\lib\site-packages\pandas\core\resample.py", line 288, in aggregate
    result, how = self._aggregate(func, *args, **kwargs)
  File "C:\Users\USER\AppData\Local\Programs\Python\Python38\lib\site-packages\pandas\core\base.py", line 416, in _aggregate
    result = _agg(arg, _agg_1dim)
  File "C:\Users\USER\AppData\Local\Programs\Python\Python38\lib\site-packages\pandas\core\base.py", line 383, in _agg
    result[fname] = func(fname, agg_how)
  File "C:\Users\USER\AppData\Local\Programs\Python\Python38\lib\site-packages\pandas\core\base.py", line 367, in _agg_1dim
    return colg.aggregate(how)
  File "C:\Users\USER\AppData\Local\Programs\Python\Python38\lib\site-packages\pandas\core\groupby\generic.py", line 267, in aggregate
    result = self._aggregate_named(func, *args, **kwargs)
  File "C:\Users\USER\AppData\Local\Programs\Python\Python38\lib\site-packages\pandas\core\groupby\generic.py", line 480, in _aggregate_named
    output = func(group, *args, **kwargs)
  File "C:\Users\USER\AppData\Local\Programs\Python\Python38\lib\site-packages\backtesting\_plotting.py", line 147, in f
    mean_time = int(bars.loc[s.index].view(int).mean())
  File "C:\Users\USER\AppData\Local\Programs\Python\Python38\lib\site-packages\pandas\core\series.py", line 667, in view
    return self._constructor(
  File "C:\Users\USER\AppData\Local\Programs\Python\Python38\lib\site-packages\pandas\core\series.py", line 313, in __init__
    raise ValueError(
ValueError: Length of passed values is 2, index implies 1.

backtesting.py custom data 적용과 buy 메소드 사용과 구매시점 이해

1. 사용자 데이터 사용하기

1.1 무작정 넣어보기

이전에는 기본으로 들어있는 sample data를 사용하였는데, 일반적인 데이터를 넣어보겠습니다.

아래와 같은 형태의 csv 파일을 넣어봤습니다.

backtest1.csv 입니다. 이 자료는 2021-03-02일자 이더리움 분단위 자료를 가져왔습니다.

실제 예제는 더 많은데 여기에서는 몇줄만 표기하였습니다.

indexdatetime,open,high,low,close,volume
2021-03-02 14:19:00,1787500.0,1790000.0,1787500.0,1790000.0,53.28931438
2021-03-02 14:20:00,1790000.0,1791000.0,1789000.0,1790500.0,181.15638692
2021-03-02 14:21:00,1790500.0,1791000.0,1787000.0,1787500.0,91.69244693
2021-03-02 14:22:00,1787500.0,1787500.0,1786500.0,1787000.0,178.84881752
...이하 생략...

csv 읽는 함수는 github 에 있는 코드를 약간 변형하였습니다.

https://github.com/kernc/backtesting.py/blob/master/backtesting/test/__init__.py

import pandas as pd

def _read_file(filename):
    from os.path import dirname, join
    return pd.read_csv(filename, index_col=0, parse_dates=True, infer_datetime_format=True)

...일부 코드 생략...
df = _read_file('../testdata/backtest1.csv')
print(df)

bt = Backtest(df, SmaCross, commission=.002,
			  exclusive_orders=True)
stats = bt.run()
bt.plot()

print(stats)

테스트해보면 아래와 같은 오류가 발생합니다.

오류를 살펴보면 columns 'Open', 'High', 'Low', 'Close', and (optionally) 'Volume' 이런식으로 되어야 한다고 되어있습니다. 순서는 관계가 없고 label 명이 대소 문자 일치하지 않으면 오류가 발생합니다.

Traceback (most recent call last):
  File "bt_sample.py", line 32, in <module>
    bt = Backtest(data, SmaCross, commission=.002,
  File "C:\Users\USER\AppData\Local\Programs\Python\Python38\lib\site-packages\backtesting\backtesting.py", line 1067, in __init__
    raise ValueError("`data` must be a pandas.DataFrame with columns "
ValueError: `data` must be a pandas.DataFrame with columns 'Open', 'High', 'Low', 'Close', and (optionally) 'Volume'


1.2 컬럼명 변경하기

pandas 컬럼의 label 변경 방법 pandas에 rename() 메소드를 이용합니다.

df = _read_file('../testdata/backtest1.csv')
print(df)
df = df.rename({'open': 'Open', 'high': 'High', 'low': 'Low', 'close': 'Close', 'volume': 'Volume'}, axis='columns')
print(df)

전과 후를 출력해 봤습니다. 

잘 변환 된것을 알 수 있습니다.

                          open       high        low      close      volume
indexdatetime
2021-03-02 14:19:00  1787500.0  1790000.0  1787500.0  1790000.0   53.289314
2021-03-02 14:20:00  1790000.0  1791000.0  1789000.0  1790500.0  181.156387
2021-03-02 14:21:00  1790500.0  1791000.0  1787000.0  1787500.0   91.692447
... 생략 ...
                          Open       High        Low      Close      Volume
indexdatetime
2021-03-02 14:19:00  1787500.0  1790000.0  1787500.0  1790000.0   53.289314
2021-03-02 14:20:00  1790000.0  1791000.0  1789000.0  1790500.0  181.156387
2021-03-02 14:21:00  1790500.0  1791000.0  1787000.0  1787500.0   91.692447
... 생략 ...



2. 좀 더 깊게 분석 해보기

2.1 캔들 차트

주식에서 흔희 볼 수 있는 캔들 차트입니다. 그런데 일반 주식의 색상이 다른 경우가 있어서 확인해 봤습니다.


여기 제공되는 캔들 차트는 빨간색이 Open가격보다 Close가격이 내려가는 것입니다. 녹색올랐다는 표시입니다.


2.2 한번만 구매/판매 해보기

앞에서 필요시 buy(), sell()을 호출하면 된다고 설명하였는데 막상 해보면 이상하게 동작합니다.

기존 사용하던 예제에서 self.i 를 추가하여 처음 한번만 구매하고 다음날 판매하도록 구현하였습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from backtesting import Backtest, Strategy
from backtesting.lib import crossover
from backtesting.test import SMA, GOOG

class SmaCross(Strategy):
	
	def init(self):
		self.i = 0;

	def next(self):
		print(len(self.data), self.data, self.data.Open)
		if self.i==0 :
			self.buy()
		elif self.i==1 :
			self.sell()
		self.i+=1

bt = Backtest(GOOG, SmaCross, commission=0.0, exclusive_orders=True)
stats = bt.run()

bt.plot()

print(stats)
print(stats['_trades'])
구현 의도는 buy()한것을 다음날 판매한것으로 구현했지만 실제 전체는 Final(0%) (이 의미는 최종 시점에 최초 100%의 금액대비 0%로 되어서 모든 돈이 사라졌다는 의미입니다.)에 거래거 두번 일어난것으로 되어 있습니다. 
거꾸로 유추해보자면 sell()도 하나의 거래로 동작하고 있습니다. sell()의 개념이 복잡하기 때문에 이런식으로 구현하지 않고 다른 방식을 찾아보았습니다.

트레이드 되는 내역은 라인 24: print(stats['_trades']) 에 의해서 확인이 가능합니다.


이번에는 sell() 메소드를 사용하지 않고 self.position.close() 을 사용하였습니다. 이 메소드는 거래를 닫는 함수입니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from backtesting import Backtest, Strategy
from backtesting.lib import crossover
from backtesting.test import SMA, GOOG

class SmaCross(Strategy):
	
	def init(self):
		self.i = 0;

	def next(self):
		print(len(self.data), self.data, self.data.Open)
		if self.i==0 :
			self.buy()
		elif self.i==1 :
			self.position.close()
		self.i+=1


bt = Backtest(GOOG, SmaCross, commission=0.0, exclusive_orders=True)
stats = bt.run()

bt.plot()

print(stats)
print(stats['_trades'])

결과를 보면 최종 100.441% 에 한번 거래시 0.004424 리턴되는 수익률이 적절히 표시되고 있습니다.

다시 한번 정리해보자면 구매시는 self.buy(), 판매시에는 self.position.close() 함수를 이용합니다.


2.3 구매/판매 시점 이해하기

next() 메소드 내에서 buy나 sell을 하게 되면 차트에서는 어떻게 표기될지와 언제 구매하도록 구현 되었는지 살펴보겠습니다. 

다음 링크에서 설명을 발견할 수 있습니다.

https://kernc.github.io/backtesting.py/doc/examples/Quick%20Start%20User%20Guide.html

Note, backtesting.py cannot make decisions / trades within candlesticksany new orders are executed on the next candle's open (or the current candle's close if trade_on_close=True). If you find yourself wishing to trade within candlesticks (e.g. daytrading), you instead need to begin with more fine-grained (e.g. hourly) data.

위와 같은 문구가 있습니다. 즉 다음번 캔들 오픈될때 거래가 실행되는데 trade_on_close=True 설정을 하면 close 될때 거래가 실행된다고 합니다. 

each next() call so that array[-1] (e.g. self.data.Close[-1] or self.sma1[-1]) always contains the most recent value, array[-2] the previous value, etc. (ordinary Python indexing of ascending-sorted 1D arrays).

또한 자세히 읽어보면 array[-1] (e.g. self.data.Close[-1] or self.sma1[-1]) 이런값으로 최신값을 접근가능하며, 그 전 데이터는 array[-2] 이런식으로 접근이 가능하다고 합니다.

위 마지막 예제를 잠시 다시 살펴보겠습니다.

라인 12에서 if self.i==0 일때 buy가 동작하도록 구현하였습니다. 그런데, i=0이면 첫번째 캔들에서 거래가 일어나야할것 같은데... 그림을 보면 3번째 캔들에서 buy가 일어나고 있음을 알 수 있습니다.

첫번째 사유는 위에서 설명드린 다음번 캔들 오픈될때 거래가 실행되기 때문입니다. 그렇더라도 하나가 더 이해가 안되는 사유가 있습니다. 두번째도 아니고 세번째 캔들이라니...

이것의 실마리는 라인 11의 print(len(self.data), self.data, self.data.Open) 코드의 출력을 보고 유추가 가능합니다.

코드가 너무 빨리지나가서 중간에 sleep함수를 넣어서 앞부분만 확인해봤습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from backtesting import Backtest, Strategy
from backtesting.lib import crossover
from backtesting.test import SMA, GOOG
import time

class SmaCross(Strategy):
	
	def init(self):
		self.i = 0;

	def next(self):
		print(len(self.data), self.data, self.data.Open)
		time.sleep(1)
		if self.i==0 :
			self.buy()
		elif self.i==1 :
			self.position.close()
		self.i+=1


bt = Backtest(GOOG, SmaCross, commission=0.0, exclusive_orders=True)
stats = bt.run()

bt.plot()

print(stats)
print(stats['_trades'])


C:\Users\USER\Documents\GitHub\dota2trader\code>python bt_sample.py
2 <Data i=2 (2004-08-23 00:00:00) Open=110.75, High=113.48, Low=109.05, Close=109.4, Volume=9137200.0> [100.   101.01]
3 <Data i=3 (2004-08-24 00:00:00) Open=111.24, High=111.6, Low=103.57, Close=104.87, Volume=7631300.0> [100.   101.01 110.75]
4 <Data i=4 (2004-08-25 00:00:00) Open=104.96, High=108.0, Low=103.88, Close=106.0, Volume=4598900.0> [100.   101.01 110.75 111.24]
5 <Data i=5 (2004-08-26 00:00:00) Open=104.95, High=107.95, Low=104.66, Close=107.91, Volume=3551000.0> [100.   101.01 110.75 111.24 104.96]
6 <Data i=6 (2004-08-27 00:00:00) Open=108.1, High=108.62, Low=105.69, Close=106.15, Volume=3109000.0> [100.   101.01 110.75 111.24 104.96 104.95]
7 <Data i=7 (2004-08-30 00:00:00) Open=105.28, High=105.49, Low=102.01, Close=102.01, Volume=2601000.0> [100.   101.01 110.75 111.24 104.96 104.95 108.1 ]
8 <Data i=8 (2004-08-31 00:00:00) Open=102.3, High=103.71, Low=102.16, Close=102.37, Volume=2461400.0> [100.   101.01 110.75 111.24 104.96 104.95 108.1  105.28]
9 <Data i=9 (2004-09-01 00:00:00) Open=102.7, High=102.97, Low=99.67, Close=100.25, Volume=4573700.0> [100.   101.01 110.75 111.24 104.96 104.95 108.1  105.28 102.3 ]

준비된 csv 데이터는 2004-08-19일 부터입니다. 그리고 제일 처음 데이터가 self.data.Open [100.   101.01] 임에 주목해주세요. 앞쪽 self.data 의 출력은 <Data i=2 (2004-08-23 00:00:00) Open=110.75, High=113.48, Low=109.05, Close=109.4, Volume=9137200.0> 이런식으로 나오는 부분은 index라서 해당 날짜는 아닌것으로 확인 됩니다. open 값만을 가지고 확인했을때 101.01은 2004-08-20임을 알 수 있습니다. 

[100.   101.01] 이런식으로 나온다는것은 self.data.Open[-1] = 101.01 이 되고 self.data.Open[-2] = 100. 이 됩니다.

위 사실은 위에서 언급한 아래 내용과 일치합니다.

또한 자세히 읽어보면 array[-1] (e.g. self.data.Close[-1] or self.sma1[-1]) 이런값으로 최신값을 접근가능하며, 그 전 데이터는 array[-2] 이런식으로 접근이 가능하다고 합니다.

그렇다고 하더라도 왜 두번째 캔들부터 들어오게 되는지는 소스의 확인 이 필요합니다.

앞쪽 몇개 데이터는 안들어올 수 있는데 warming up 때문에 그렇습니다. 위 소스에서 start가 0이 아니라 1+어떤 값이 되기 때문에 index가 skip되기 때문입니다.

정리, next() 메소드 안에서 오늘 날짜의 데이터는 self.data.Open[-1], self.data.High[-1], self.data.Low[-1], self.data.Close[-1] 로 접근이 가능합니다. 그리고 그이전 날짜의 데이터는  self.data.Open[-2], self.data.High[-2], self.data.Low[-2], self.data.Close[-2] 로 접근이 가능합니다.


2.3 trade_on_close=True 사용해보기


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from backtesting import Backtest, Strategy
from backtesting.lib import crossover
from backtesting.test import SMA, GOOG
import time

class SmaCross(Strategy):
	
	def init(self):
		self.i = 0;

	def next(self):
		if self.i==0 :
			self.buy()
		elif self.i==1 :
			self.position.close()
		self.i+=1


bt = Backtest(GOOG, SmaCross, commission=0.0, exclusive_orders=True, trade_on_close=True)
stats = bt.run()

bt.plot()

print(stats)
print(stats['_trades'])

좌측이 기존(다음날 open 기준), 우측이 trade_on_close=True 사용한 예제 입니다. 당일 close 기준으로 변경되었습니다.



2022년 1월 6일 목요일

backtesting 이란? backtesting.py 이해와 분석

backtesting 이란? backtesting.py 이해와 분석

backtesting.py custom data 적용과  buy() 메소드 사용과 구매시점 이해

backtesting.py 나만의 전략 my Strategy 만들어보기

1. backtesting 이란?

데이터에서 예측 모델을 테스트하는것을 말합니다. 

다시 좀 더 풀어서 설명해보겠습니다. 주식, 코인 등의 예전 데이터를 가지고 예측하는 모델 또는 사고 파는 전략을 만들었을때 이것을 시뮬레이션 해보는 것입니다. 

위에 말한 데이터의 경우 테스트의 결과는 이익률로 나타날 것입니다.


2. backtesting.py 분석하는 이유

backtesting을 하면서 예측 모델이나 사고 파는 전략을 만들때 기존 library가 마음에 들지 않거나 기능추가를 해야 하는 경우가 있을 수 있는데 이때 소스가 존재한다면 좋기 때문에 opensource쪽으로 알아보았습니다.

github에서 검색시 아래 내용들이 검색되었는데, 복잡하지 않고 소스 코드가 단순한 backtesting.py 을 분석해보기로 결정하였습니다.

https://github.com/mementum/backtrader

https://github.com/kernc/backtesting.py


3. 설치 방법

pip install backtesting


4. sample code 분석

아래가 github에서 제공하는 예제 코드입니다.

from backtesting import Backtest, Strategy
from backtesting.lib import crossover

from backtesting.test import SMA, GOOG


class SmaCross(Strategy):
	def init(self):
		price = self.data.Close
		self.ma1 = self.I(SMA, price, 10)
		self.ma2 = self.I(SMA, price, 20)

	def next(self):
		if crossover(self.ma1, self.ma2):
			self.buy()
		elif crossover(self.ma2, self.ma1):
			self.sell()


bt = Backtest(GOOG, SmaCross, commission=.002,
			  exclusive_orders=True)
stats = bt.run()
bt.plot()

print(stats)


4.1 Backtest

backtest class의 생성자에 넘어가는 인자를 살펴보겠습니다. 예제에서는 아래와 같이 설정하고 있습니다.

Backtest(GOOG, SmaCross, commission=.002, exclusive_orders=True)

GOOG 는 주식 일별 데이터입니다.

자료는 아래 링크를 눌러보면 주식의 데이터의 일반적인 형태(날짜,open 시초가,high 최고가,low 최저가,close 종가,volume 거래량)를 취하고 있습니다.

https://github.com/kernc/backtesting.py/blob/master/backtesting/test/GOOG.csv


만약 다른 데이터를 backtesting 하려면 GOOG말고 다른 자료를 넣어야 합니다. 이 부분은 나중에 다른 예제로 만들어 보겠습니다.

SmaCross 는 사고 파는 전략을 구현한 클래스입니다.

여기에서 SmaCross 클래스는 Strategy 클래스를 상속받아 init(), next()를 만들어 주고 next()메소드 안에서 사야한다면 buy(), 팔아야한다면 sell()을 호출해주면 됩니다.

commission 인자가 있는데 이것은 중계 수수료입니다. order가 일어날때 수수료 만큼 %더가져간다는 의미입니다. 0.1은 10%, 위의 예제에서는 0.2%가 됩니다.

exclusive_orders True는 새 주문이 발생하면 이전 주문했던것은 자동으로 닫히게 됩니다.

실행은 제일 아래쪽 stats = bt.run() 에 의해서 실행이 됩니다.


4.2 Strategy

다음은 SmaCross 클래스 설명입니다. Strategy 클래스를 상속 받아서 init next를 구현합니다.

	def init(self):
		price = self.data.Close
		self.ma1 = self.I(SMA, price, 10)
		self.ma2 = self.I(SMA, price, 20)

여기에서의 init 코드는 위와 같습니다.

self.data.Close는 GOOG(입력데이터) csv값의 Close(종가)를 의미합니다. 그런데 self.data가 array type이기 때문에 price에 값이 하나가 들어가는게 아니라 종가 배열이 들어가게 됩니다.

self.ma1, self.ma2 이런 처리는 이동 평균값입니다. 

self.ma1 = self.I(SMA, price, 10)

여기코드에 의해서 price 값 10개를 이용하여 평균값으로 만들어서 ma1에 기록합니다. 이것을 이동하면서 ma1배열에 만들게 됩니다.

self.ma2 = self.I(SMA, price, 20)

위 코드는 데이터가 하루씩 들어있기 때문에 20일 평균값이 들어갑니다.

이동 평균 관련해서는 WIKI를 참고 하시기 바랍니다.

https://ko.wikipedia.org/wiki/이동평균

여기에서 만들려고 하는 전략은 10일 이동선과 20일 이동선이 cross가 일어나면 사고 팔고 하는 전략입니다.

입력을 들어가는 ma1,ma2의 값 형태를 알아보기 위해서 코드를 약간 변형하여 값을 출력해보았습니다. (SmaCross는 자신의 사고 파는 전략이니 이름을 바꾸어도 됩니다.)

class SmaCross(Strategy):
	def init(self):
		price = self.data.Close
		print(type(price))
		print(price)
		self.ma1 = self.I(SMA, price, 2)
		print(type(self.ma1))
		print(self.ma1)
		self.ma2 = self.I(SMA, price, 3)
		print(type(self.ma2))
		print(self.ma2)

실행 시키면 아래와 같은 형태가 되는데 이부분을 위의 csv 값과 비교해보면 이해가 될겁니다.

<class 'backtesting._util._Array'>
[100.34 108.31 109.4  ... 799.78 801.2  806.19]
<class 'backtesting._util._Indicator'>
[    nan 104.325 108.855 ... 794.955 800.49  803.695]
<class 'backtesting._util._Indicator'>
[         nan          nan 106.01666667 ... 793.56       797.03666667
 802.39      ]
이동 평균을 눈에 띄기 쉽게 2, 3으로 변형해보았고, 2인 경우 어떻게 계산되는지 위 그림에서 표시하였습니다. nan는 값이 없음을 의미합니다. (2일 이동 평균이면 nan이 하나 나옵니다. 그림에서 보시면 제일 아래쪽에 값이 없기 때문입니다.)
마찬가지로 3일 이동 평균이면 nan nan 이 두개 나오고 (100.34+108.31+109.4)/3이 첫번째가 될겁니다. 값은 106.0166666666667 이 계산됩니다.
출력값 확인해보면 아래와 같은 형태이고 

[         nan          nan 106.01666667 ... 793.56       797.03666667

 802.39      ]

106.01666667 로 출력되었음을 알 수 있습니다.

여기까지는 이동 평균에 대한 설명이고, 이동 평균이 값이 Cross되면 buy, sell은

다음 코드로 구현이 되어있습니다.

	def next(self):
		if crossover(self.ma1, self.ma2):
			self.buy()
		elif crossover(self.ma2, self.ma1):
			self.sell()

여기에서 crossover는 미리 준비가 되어있는 라이브러리입니다. 두개의 배열을 가지고 -2 ,-1 의 인덱스의 들어있는 값을 비교하게 됩니다. 관련 코드는 라이브러리 쪽에 있으며, 아래 내용을 참고 하시면 됩니다.

https://github.com/kernc/backtesting.py/blob/master/backtesting/lib.py

def crossover(series1: Sequence, series2: Sequence) -> bool:
    """
    Return `True` if `series1` just crossed over (above)
    `series2`.
        >>> crossover(self.data.Close, self.sma)
        True
    """
    series1 = (
        series1.values if isinstance(series1, pd.Series) else
        (series1, series1) if isinstance(series1, Number) else
        series1)
    series2 = (
        series2.values if isinstance(series2, pd.Series) else
        (series2, series2) if isinstance(series2, Number) else
        series2)
    try:
        return series1[-2] < series2[-2] and series1[-1] > series2[-1]
    except IndexError:
        return False


5. 그래프

샘플을 실행시키면 브라우저에 그래프가 뜨게 되면서 많은 정보가 나오게 됩니다.

가장 중요한 항목은 중간에 있는 이익을/손실로 나는 비율이 나오게 됩니다. 또한 한가지 알고 있어야하는점은 buy, sell을 호출하면 곧장 매매가 일어나는게 아니라 다음날 매매가 일어나게 됩니다.



6. 끝마지며

간단하게 샘플에 대해서 알아봤습니다.
다음에는 다른 데이터를 이용해서 backtesting을 하거나, 전략을 바꿔 본다던가 단순 전략이 아닌 예측 결과값을 통한 결과의 backtesting을 시도해보록 하겠습니다.