Is there a Python library with which I could tick-level backtest the famous Spot Grid Trading crypto strategy? I already did the tick data download part from data.binance.vision, although in my attempt I have used backtesting.py that seems to not be suitable for tick-level backtests, as discussed here. The grid strategy is pretty straightforward, so I assume it should be fairly easy to backtest on tick-level. I assume there are others who have already achieved this, I just can't find it, so that's why I'm asking the question.
The strategy is described here and I also found a few open source references on GitHub:
- https://github.com/xzmeng/crypto-grid-backtest/blob/master/grid/backtest.py
- https://github.com/webclinic017/aibitgo/blob/master/strategy/GridStrategyPercent.py
- https://github.com/ulbdir/trading_bot/blob/main/GridStrategy.py
What I have so far
from backtesting import Backtest
from grid.grid import GridStrategy
from tick_data.data import Kind, load_data
if __name__ == "__main__":
# 1s interval pickle
df = load_data(symbol="ETHUSDT", start="2022-12-16", end="2022-12-16", kind=Kind.SPOT, tz="UTC")
df = df.resample("1s").first().dropna()
print(f'{df}')
# Backtest
bt = Backtest(df, GridStrategy, cash=10_000, commission=.001, exclusive_orders=True)
stats = bt.run()
bt.plot()
import numpy as np
import pandas as pd
from enum import Enum
from backtesting import Strategy
class GridType(Enum):
ARITHMETIC = 1
GEOMETRIC = 2
class GridStrategy(Strategy):
lower_limit = 2000
upper_limit = 10000
grid_count = 4
grid_type = GridType.ARITHMETIC
grids = []
current_position = None
def init(self):
self.grids = self.get_grids(self.lower_limit, self.upper_limit, self.grid_count, self.grid_type)
print(f'{self.grids}')
def next(self):
pass
@staticmethod
def get_grids(lower_limit, upper_limit, grid_count, grid_type=GridType.ARITHMETIC):
if grid_type == GridType.ARITHMETIC:
grids = np.linspace(lower_limit, upper_limit, grid_count + 1)
elif grid_type == GridType.GEOMETRIC:
grids = np.geomspace(lower_limit, upper_limit, grid_count + 1)
else:
print("not right range type")
return grids
import io
import logging
from concurrent.futures import ThreadPoolExecutor
from datetime import date
from enum import Enum
from pathlib import Path
from typing import Optional, Union
from zipfile import ZipFile
from datetime import datetime
import pandas as pd
import httpx
from pandas import DataFrame
DATA_DIR = Path.cwd().joinpath("data")
def create_logger():
logger_ = logging.getLogger(__name__)
logger_.setLevel(logging.INFO)
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
handler = logging.StreamHandler()
handler.setFormatter(formatter)
logger_.addHandler(handler)
return logger_
logger = create_logger()
class Kind(Enum):
SPOT = "spot"
FUTURES_UM = "futures/um"
FUTURES_CM = "futures/cm"
class DataLoader:
def __init__(
self,
kind: Kind,
symbol: str,
start: Union[date, str],
end: Union[date, str],
tz: str = None,
) -> None:
self.kind = kind
self.symbol = symbol
self.start = start
self.end = end
self.tz = tz
def load_data(self) -> pd.DataFrame:
with ThreadPoolExecutor(max_workers=20) as executor:
dfs = list(
executor.map(self.load_daily_data, pd.date_range(self.start, self.end))
)
df = pd.concat(dfs)
if self.tz:
df.index = df.index.tz_localize("utc").tz_convert(self.tz)
return df
def load_daily_data(self, dt: date) -> Optional[pd.DataFrame]:
try:
return self.load_local_daily_data(dt)
except FileNotFoundError:
return self.download_daily_data(dt)
def load_local_daily_data(self, dt: date) -> pd.DataFrame:
pickle_path = self.get_daily_pickle_path(dt)
return pd.read_pickle(pickle_path)
def download_daily_data(self, dt: date) -> Optional[pd.DataFrame]:
logger.info(f'Downloading {self.symbol} {datetime.strftime(dt, "%Y-%m-%d")}')
url = f'https://data.binance.vision/data/{self.kind.value}/daily/trades/{self.symbol}/' \
f'{self.symbol}-trades-{datetime.strftime(dt, "%Y-%m-%d")}.zip'
resp = httpx.get(url)
resp.raise_for_status()
with ZipFile(io.BytesIO(resp.content)) as zf:
with zf.open(zf.namelist()[0]) as f:
df = pd.read_csv(f, usecols=[1, 4], names=["price", "datetime"])
df["datetime"] = pd.to_datetime(df.datetime, unit="ms")
df.set_index("datetime", inplace=True)
# df = df.resample("1s").first().dropna()
# df.price.resample("1s").agg({
# "Open": "first",
# "High": "max",
# "Low": "min",
# "Close": "last"
# })
pkl_path = self.get_daily_pickle_path(dt)
Path(pkl_path).parent.mkdir(parents=True, exist_ok=True)
df.to_pickle(pkl_path)
return df
def get_daily_pickle_path(self, dt: date) -> Path:
return DATA_DIR.joinpath(self.kind.value, f"{self.symbol}-{dt.year}-{dt.month}-{dt.day}.pkl")
def load_data(
symbol: str,
start: Union[date, str],
end: Union[date, str] = date.today(),
kind: Union[Kind, str] = Kind.SPOT,
tz: str = "UTC",
) -> DataFrame:
if isinstance(kind, str):
kind = {"spot": Kind.SPOT, "cm": Kind.FUTURES_CM, "um": Kind.FUTURES_UM}[kind]
return DataLoader(kind, symbol, start, end, tz).load_data()