Создание Трейдинг-бота на Python
Создание Трейдинг-бота на Python (часть 1)
Вступление
Этот проект не выходил у меня из головы уже довольно давно. Вот как я сформулировал задачу для себя , перед написанием кода:
- Что мне нужно знать, чтобы создавать прибыльные алгоритмы автоматической торговли, развёртывать их в облаке и отслеживать результат во время их работы?
Компоненты
Проект состоит из трёх основных компонентов:
- Класс бота
– Обновляет текущие балансы портфеля
– Обновляет рыночные данные
– Вычисляет новый прогноз на основе новых данных
– Рассчитывает новый “идеальный портфель” на основе прогноза
– Выполняет заказы для достижения такого портфеля
– Сохраняет то, что произошло с csv-файлами (данные, стоимость портфеля, прогноз, заказы и т.д.)
- Панель управления
– Считывает данные из csv-файлов, которые бот постоянно обновляет
– Показывает всю информацию в удобном пользовательском интерфейсе
- Модель
– Самый модульный компонент в проекте
– В этой статье мы будем использовать модель заполнителя
В этой статье описывается класс ботов. Обратите внимание, что это не было учтено при производстве.
Класс bot
Поскольку прогнозы делает модель, а сбор и обработка данных в данном случае не очень увлекательны (объединение строк в существующий фрейм данных, сохранение в .csv, простые операции), преимущество класса bot состоит в:
- Правильном соблюдение сроков выполнения операций
- Преобразовании прогнозов модели в ордера для биржи
- Размещении действительных ордеров на бирже для получения желаемого портфеля -Это включает в себя округление чисел, проверку доступных остатков и проверку минимальной стоимости заказа
- Стабильности, чтобы не прерывать торговлю в критические моменты
Бот предназначен для запуска алгоритма каждые 1 минуту, что достаточно быстро для раннего выявления тенденций.
Запуск бота
config = dotenv_values(".env")
api_key_testnet = config["API_Key_Testnet"]
api_secret_testnet = config["Secret_Key_Testnet"]
modelName = config["modelName"]
async def main():
smartBot1 = tradingBot(
modelName=modelName,
symbol="BTCUSDT",
minPctChange=2/100,
exposureMultiplier=100,
API = [api_key_testnet, api_secret_testnet],
)
await asyncio.gather(
smartBot1.mainLiveTradeLoop()
)
if __name__ == "__main__":
asyncio.run(main())
- “ModelName” используется для определения местоположения папки модели, которая содержит её масштабаторы и параметры. Подробнее об этих параметрах позже.
- “symbol” определяет, на каком рынке Binance будет осуществляться торговля.
- ”minPctChange” ограничивает размер изменений, которые боту разрешено вносить в свой портфель (позволяет избежать рассылки спама заказами)
- “exposureMultiplier” входит в уравнение, которое преобразует прогноз в идеальную подверженность активу
Этот раздел кода расположен в конце “liveBotClass.py ”, который определяет класс bot, поэтому бота можно инициализировать, введя в терминале из корневой папки:
python scripts/liveBotClass.py
Инициализация бота
Как только экземпляр бота создан, он запускает функцию __init__
:
class tradingBot:
# Initialize the class
def __init__(
self,
modelName,
symbol,
minPctChange,
exposureMultiplier,
API,
):
self.name = f"{modelName}_bot"
print(f"\nHello, I'm {self.name}.\n")
self.symbol = symbol
self.minPctChange = minPctChange
self.exposureMultiplier = exposureMultiplier
self.API = API
self.modelName = modelName
self.initializeBot()
print("Starting trading.\n")
#-----
Внутри self.initializeBot()
находятся все функции инициализации, которые вызываются только при инициализации бота. Они проверяют, существует ли уже папка для этого бота, если да, то загружаются все прошлые данные, которые бот сохранил перед завершением работы. В противном случае он создаёт папку и файлы. Таким образом, бот может продолжить с того места, на котором остановился, если что-то пойдет не так.
Если вы обратите внимание, self.mainTradeLoop()
был вызван внутри асинхронной функции, чтобы запустить бота:
##### ASYNC LOOP #####
async def mainLiveTradeLoop(self):
while True:
print("\n---|---|---|---|---|---|---|---|---|---\n")
while True:
self.refreshAll()
now = dt.datetime.now(pytz.timezone("UTC"))
timeGap = now.minute-self.lastMarketDataTS.minute
if timeGap <= 1:
self.smartSignals()
self.saveFinish()
break
print(f"DATA IS LATE: now-{now.minute} vs last-{self.lastMarketDataTS.minute+1} -> timeGap = {timeGap}\n")
print("Sleeping 1 second")
await asyncio.sleep(1)
now = dt.datetime.now(pytz.timezone("UTC"))
timeToWait = round(61-(now.second+(now.microsecond)/1000000),4)
print(f"Seconds now: {now.second}")
print(f"Waiting {timeToWait} seconds")
await asyncio.sleep(timeToWait)
#-----
Эта функция вызывает RefreshAll(), smartSignals()
и saveFinish()
каждую минуту. Перед вызовом smartSignals()
и saveFinish()
она проверяет актуальность данных путём сравнения текущей минуты с самой последней минутой в рыночных данных (lastMarketDataTS).
Большая часть кода в основном цикле служит цели обеспечения правильного выбора времени.
Функции обновления
RefreshAll()
– это короткая функция, вызывающая множество других функций:
##### REFRESH BOT #####
def refreshAll(self):
# update balances and average price
self.refreshPortfolio()
# update market data
self.refreshSaveData()
# update prediction to enable decision making
self.refreshPred()
def refreshPortfolio(self):
while True:
try:
client = Client(self.API[0], self.API[1], testnet=True)
balances = pd.DataFrame.from_records(client.get_account()["balances"])
self.price = float(client.get_avg_price(symbol=self.symbol)["price"])
client.close_connection()
except Exception as e:
if isinstance(e, socket.error):
print(f"Connection error:\n{e}")
else:
print(f"Ooops there was a problem refreshing the portfolio:\n{e}")
else:
newNominal = balances[(balances["asset"]==self.symbol[:-4]) | (balances["asset"]=="USDT")]["free"].values
self.Portfolio["nominal"] = newNominal
self.Portfolio["nominal"] = self.Portfolio["nominal"].astype("float")
self.Portfolio["inUSD"] = self.Portfolio["nominal"]*[self.price,1]
self.pfValUSD = self.Portfolio["inUSD"].sum()
self.pfValNonUSD = (self.Portfolio["inUSD"]/[self.price,self.price]).sum()
self.cryptoRatio = self.Portfolio["inUSD"][0]/self.Portfolio["inUSD"].sum()
break
def refreshSaveData(self):
if dt.datetime.now(pytz.timezone("UTC")) - dt.datetime.fromtimestamp(self.lastMarketDataTS.value/1000000000,tz=pytz.timezone("UTC")) > dt.timedelta(seconds=121):
newRow = BinanceData.download(
self.symbol,
start = self.lastMarketDataTS + dt.timedelta(minutes=1),
end = dt.datetime.now(pytz.timezone("UTC")) - dt.timedelta(minutes=1),
interval="1m").get(["Open", "High", "Low", "Close", "Volume"])
self.marketData = pd.concat([self.marketData,newRow]).iloc[-self.dataLength:,:]
self.lastMarketDataTS = self.marketData.index[-1]
# SAVE
newRow.to_csv(f"{self.botFolderPath}/{self.name}_data.csv",mode="a",header=False)
def refreshPred(self):
processedData = processData(self.marketData,self.modelParamsDict["timePeriods"],scalers=self.scalers)
modelPred = self.model.predict(processedData.iloc[-1:])
descaledModelPred = self.targetScaler.inverse_transform(modelPred.reshape(-1, 1))[0][0]
self.currentPrediction = (descaledModelPred/100)*self.price
def saveFinish(self):
self.refreshPortfolio()
predDict = {"Timestamp":self.lastMarketDataTS,"pfVal":self.pfValUSD,"cryptoRatio":self.cryptoRatio,"prediction":self.currentPrediction}
predDF = pd.DataFrame(predDict,index=[0])
predDF.to_csv(f"{self.botFolderPath}/{self.name}_preds.csv",mode="a",index=False,header=False)
#-----
Хотя она немного длиннее, этот код не должен быть трудным для понимания. Чтобы было понятнее, вот смысловая карта RefreshAll()
:
Зелёные прямоугольники представляют обновления в атрибутах класса, а жёлтые прямоугольники представляют обновления в csv-файлах.
Функция saveFinish()
не используется в RefreshAll()
, она вызывается после smartSignals()
.
Как только выполняется функция RefreshAll()
, бот обновляется и готов принять решение.
Торговые функции
Все торговые вычисления выполняются с помощью функции smartSignals()
, точно так же, как все обновления данных выполняются с помощью вызова функции RefreshAll()
. Эти функции в сумме составляют изрядный объём кода, но являются последней частью класса bot.
##### MAKE ORDERS #####
def smartSignals(self):
# in this example the model predicts a moving average. predictedMA is the current value of the moving average the model predicts
predictedMA = talib.MA(self.marketData["Close"],timeperiod=self.modelParamsDict["rollingMeanWindow"]).iloc[-1]
# if price is above current and predicted MA, this means the price is going down. thus, sell all
if (self.price > predictedMA) & (self.price > self.currentPrediction):
self.targetRatio = 0
# else, compute predicted percentual change of the MA and use it to generate the target ratio of exposure
else:
percentualPred = (self.currentPrediction-predictedMA)/predictedMA
# the operation below is mostly arbitrary and can be optimized through backtests
# it converts the predicted percentual change of the MA into a target ratio of exposure
self.targetRatio = min(max(percentualPred*self.exposureMultiplier,0),1)
# if the target ratio is below minPctChange, set it to 0
if self.targetRatio < self.minPctChange:
self.targetRatio = 0
# with targetRatio set, we now place orders to achieve such ratio
self.achieveIdealPortfolio()
def achieveIdealPortfolio(self,saveOrder=True):
# utility functions (basically to avoid minimum notional)
def upThenDown():
self.placeOrder(sell=False,amount=(self.pfValNonUSD*(1-self.cryptoRatio)),saveOrder=saveOrder, refreshPf=True)
self.placeOrder(sell=True,amount=(self.pfValNonUSD*(1-self.targetRatio)),saveOrder=saveOrder)
def downThenUp():
self.placeOrder(sell=True,amount=(self.pfValNonUSD*self.cryptoRatio),saveOrder=saveOrder, refreshPf=True)
self.placeOrder(sell=False,amount=(self.pfValNonUSD*self.targetRatio),saveOrder=saveOrder)
# if the difference between the target ratio and the current ratio is less than minPctChange, do nothing
percentChange = self.targetRatio-self.cryptoRatio
if abs(percentChange)>self.minPctChange:
minNotionalThreshold = 12
minNotionalRatio = minNotionalThreshold/self.pfValUSD
# avoid minimum notional
if abs(percentChange)<minNotionalRatio:
if (self.cryptoRatio>1.2*minNotionalRatio):
downThenUp()
else:
upThenDown()
else:
self.placeOrder(sell=(percentChange<0),amount=abs(self.pfValNonUSD*percentChange),saveOrder=saveOrder)
def placeOrder(self, sell, amount, saveOrder=True, refreshPf=False):
def roundAndSendOrder(self, client, sell, amount):
amountToOrder = math.floor(amount*10000)/10000
print(f"\n-----> {'SELL' if sell else 'BUY'} {amountToOrder} {self.symbol[:-4]} | {round(amountToOrder*self.price,2)} USD | {round((amountToOrder*self.price*100)/self.pfValUSD,2)}% <-----\n")
if sell:
order = client.order_market_sell(
symbol= self.symbol,
quantity = amountToOrder)
else:
order = client.order_market_buy(
symbol= self.symbol,
quantity = amountToOrder)
return order
while True:
try:
client = Client(self.API[0], self.API[1], testnet=True)
# SELL
if sell:
amountCrypto = self.pfValNonUSD*self.cryptoRatio
if amount > amountCrypto:
print("Not enough crypto to sell!")
amount = amountCrypto*0.9999
order = roundAndSendOrder(self, client, sell, amount)
# BUY
else:
amountUSD = self.pfValNonUSD*(1-self.cryptoRatio)
if amount > amountUSD:
print("Not enough USD to buy!")
amount = amountUSD*0.9999
order = roundAndSendOrder(self, client, sell, amount)
client.close_connection()
except Exception as e:
if isinstance(e, socket.error):
print(f"Connection error:\n{e}")
else:
print(f"Ooops there was a problem placing an order:\n{e}")
else:
if refreshPf: self.refreshPortfolio()
if saveOrder: self.saveOrder(order)
break
def saveOrder(self,order):
order['effectivePrice'] = [round(float(order['cummulativeQuoteQty'])/float(order['executedQty']),2)]
order['pfValue'] = self.pfValUSD
order.pop('fills')
orderDF = pd.DataFrame.from_dict(order)
orderDF = orderDF[['symbol','pfValue','orderId','executedQty','cummulativeQuoteQty','effectivePrice','side','status','type','transactTime']].copy()
orderDF["transactTime"] = pd.to_datetime(orderDF["transactTime"],unit="ms")
orderDF.to_csv(f"{self.botFolderPath}/{self.name}_log.csv",mode="a",index=False,header=False)
#-----
Чтобы было понятнее, вот смысловая карта smartSignals()
:
Как вы можете видеть, существует пара операций для проверки минимального значения, округления десятичных знаков заказов, двойной проверки доступных остатков и т.д. Очень важно, чтобы бот работал стабильно, чтобы не прерывать торговлю в ключевые моменты.
Наконец, бот вызывает saveFinish()
в конце процедуры. Функция saveFinish()
сохраняет временную метку, стоимость портфеля, подверженность риску активов и текущий прогноз в preds.csv
. Эти данные позже используются для отображения пользовательского интерфейса.
Работает ли это?
Вы можете клонировать этот репозиторий для тестирования бота.
В следующей статье мы рассмотрим пользовательский интерфейс бота, который создадим с помощью streamlit.