Создание Трейдинг-бота на 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():

Создание Трейдинг-бота на Python

Зелёные прямоугольники представляют обновления в атрибутах класса, а жёлтые прямоугольники представляют обновления в 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():

Создание Трейдинг-бота на Python

Как вы можете видеть, существует пара операций для проверки минимального значения, округления десятичных знаков заказов, двойной проверки доступных остатков и т.д. Очень важно, чтобы бот работал стабильно, чтобы не прерывать торговлю в ключевые моменты.

Наконец, бот вызывает saveFinish() в конце процедуры. Функция saveFinish() сохраняет временную метку, стоимость портфеля, подверженность риску активов и текущий прогноз в preds.csv. Эти данные позже используются для отображения пользовательского интерфейса.

Работает ли это?

Вы можете клонировать этот репозиторий для тестирования бота.

Создание Трейдинг-бота на Python

В следующей статье мы рассмотрим пользовательский интерфейс бота, который создадим с помощью streamlit.

+1
2
+1
6
+1
2
+1
1
+1
0

Ответить

Ваш адрес email не будет опубликован. Обязательные поля помечены *