Торговый робот для веб-дизайнеров

arcan1s 6 октября в 10:40 4,1k

Написание торговых роботов, как правило, достаточно трудоемкая задача — помимо понимания принципов торговли (равно как и представления о том, как та или иная стратегия выглядит), необходимо знать и уметь работать с протоколами, используемыми для торговли. Вкратце — существуют две основные группы протоколов, которые предоставляются биржей или брокерами: FIX, в котором без бутылки не разобраться, и проприетарный бинарный протокол, который редко бывает лучше. Это приводит к одной из двух проблем: либо код выглядит так, что любой джуниор схватится за голову, либо хороший, красивый код, который умеет делать примерно ничего (а то, что умеет, делает с разными неожиданными проблемами).



Для того чтобы решить обозначенные выше проблемы и привлечь как можно больше участников, брокеры иногда представляют обычное HTTP API с сериализацией в json/xml/что-то более экзотическое. В частности, подобный метод общения с биржей является едва ли не единственным для ряда модных стартапов, например, биткоин-бирж. Мы решили не отставать от них и недавно представили дополнение к нашему API (подробнее про его старые возможности можно почитать на Хабре здесь и здесь), которое позволяет пользователю также и торговать.


Под катом не совсем пятничная статья-туториал про то, как можно было бы торговать через наше HTTP API.


Реализовывать мы будем робота, который торгует по grid-стратегии. Выглядит она следующим образом:


  1. Выберем шаг цены (сетки) step и количество одной заявки size.
  2. Сохраняем текущую цену.
  3. Получаем новую цену и сравним с сохраненной.
  4. Если цена изменилась меньше чем на step, то вернуться к п.3.
  5. Если цена изменилась больше чем на step, то:
    a. Если цена увеличилась, то ставим заявку с количеством size на продажу.
    b. Если уменьшилась — то на покупку с таким же количеством.
  6. Вернуться к п.2.

Наглядно на графике биткоина стратегия выглядит следующим образом:



Вместо языка программирования выберем Python — из-за простоты работы с некоторыми штуками и скорости разработки. На волне хайпа для тестирования робота возьмем криптовалюты, скажем, лайткоины LTC.EXANTE (потому что на биткоин денег нет).


Авторизация


Как и раньше, необходимо иметь аккаунт на https://developers.exante.eu (к слову, можно авторизоваться и через GitHub). Единственное отличие от старых гайдов — для торговли нам понадобится торговый аккаунт, для создания которого необходимо залогиниться в личный кабинет со свежесозданным пользователем.


В этот раз для авторизации робота нет необходимости танцевать с бубном вокруг jwt.io — приложение будет запущено на компьютере/сервере разработчика, поэтому нет необходимости вставлять дополнительные уровни безопасности (и трудности) в виде токенов. Вместо это мы будем использовать обычный http basic auth:



Полученные Application ID — имя пользователя, а колонка Value в Access Keys – собственно наш пароль.


Получение котировок


Поскольку роботу нужно знать, когда и как торговать, нам опять нужно получать данные о рынке. Для этого напишем небольшой класс:


class FeedAdapter(threading.Thread):
    def __init__(self, instrument: str, auth: requests.auth.HTTPBasicAuth):
        super(FeedAdapter, self).__init__()
        self.daemon = True

        self.__auth = auth
        self.__stream_url = 'https://api-demo.exante.eu/md/1.0/feed/{}'.format(
            urllib.parse.quote_plus(instrument))

Я напомню о необходимости кодирования имени инструмента, потому что оно может содержать, например, слэш / (EUR/USD.E.FX). Для собственно получения данных напишем метод-генератор:


    def __get_stream(self) -> iter:
        response = requests.get(
            self.__stream_url, auth=self.__auth, stream=True, timeout=60,
            headers={'accept': 'application/x-json-stream'})
        return response.iter_lines(chunk_size=1)

    def run(self) -> iter:
        while True:
            try:
                for item in self.__get_stream():
                    # парсим ответ сервера
                    data = json.loads(item.decode('utf8'))
                    # к сожалению, API на текущий момент имеет несколько 
                    # различный набор полей для ответа. Наличие поля event 
                    # означает служебное сообщение, иначе - цены в с полями 
                    # {timestamp, symbolId, bid, ask}
                    if 'event' in data:
                        continue
                    # а вот и наши котировки
                    yield data
            # обработка стандартных ошибок
            except requests.exceptions.Timeout:
                print('Timeout reached')
            except requests.exceptions.ChunkedEncodingError:
                print('Chunk read failed')
            except requests.ConnectionError:
                print('Connection error')
            except socket.error:
                print('Socket error')
            time.sleep(60)

Адаптер к торговой сессии


Для того чтобы торговать, помимо стандартных знаний (финансовый инструмент, размер и цена заявки, тип заявки), нужно знать свой аккаунт. Для этого, к сожалению, необходимо авторизоваться в личном кабинете и попробовать нашу браузерную торговую платформу. К счастью, в будущем API будет доработано — появится возможность узнать информацию о своем пользователе (включая торговые аккаунты) не отходя от кассы. Аккаунт будет в верхнем правом углу, вида ABC1234.001:



class BrokerAdapter(threading.Thread):
    def __init__(self, account: str, interval: int, auth: requests.auth.HTTPBasicAuth):
        super(BrokerAdapter, self).__init__()
        self.__lock = threading.Lock()
        self.daemon = True
        self.__interval = interval

        self.__url = 'https://api-demo.exante.eu/trade/1.0/orders'

        self.__account = account
        self.__auth = auth
        # внутреннее хранилище заявок для проверки их состояния
        self.__orders = dict()

Как вы могли заметить, префикс для постановки заявок и получения рыночных данных отличается — /trade/1.0 против /md/1.0. interval здесь служит для указания интервала между запросами данных по заявкам с сервера (не советовал бы ставить слишком маленький во избежание бана):


    def order(self, order_id: str) -> dict:
        response = requests.get(self.__url + '/' + order_id, auth=self.__auth)
        if response.ok:
            return response.json()
        return dict()

Подробнее о полях в ответе можно почитать здесь; нас же будут интересовать только поля orderParameters.side, orderState.fills[].quantity и orderState.fills[].price для расчета потерь профита.


Метод для постановки заявки на сервер:


    def place_limit(self, instrument: str, side: str, quantity: int,
                    price: float, duration: str='good_till_cancel') -> dict:
        response = requests.post(self.__url, json={
            'account': self.__account,
            'duration': duration,
            'instrument': instrument,
            'orderType': 'limit',
            'quantity': quantity,
            'limitPrice': price,
            'side': side
        }, auth=self.__auth)
        try:
            # заявка поставлена, нас интересует только ее ID
            return response.json()['id']
        except KeyError:
            # ответ сервера содержит какую-то читаемую ошибку
            print('Could not place order')
            return response.json()
        except Exception:
            # все сломалось, время выводить свои деньги
            print('Unexpected error occurs while placing order')
            return dict()

Данный участок кода содержит два новых непонятных словосочетания:


  • {'orderType': 'limit'} означает, что мы ставим так называемую лимитную заявку, чтобы плохие брокер-биржа не нагрели нас на маркетной заявке, которая (в отличие от лимитной) может исполниться по произвольной разумной (а иногда и не очень) цене.
  • {'duration': 'good_till_cancel'} означает время жизни заявки, в данном случае — пока трейдеру не надоест (или что-то не сломается).

Watchdog для заявок


Работать он будет в бесконечном цикле, а результаты работы сваливать в stdout:


    def run(self) -> None:
        while True:
            with self.__lock:
                for order_id in self.__orders:
                    state = self.order(order_id)
                    # проверить, изменилось ли состояние заявки
                    if state == self.__orders[order_id]:
                        continue
                    print('Order {} state was changed'.format(order_id))
                    self.__orders[order_id] = state
                    # давайте посчитаем наши филы, если они были
                    filled = sum(
                        fill['quantity'] for fill in state['orderState']['fills']
                    )
                    avg_price = sum(
                        fill['price'] for fill in state['orderState']['fills']
                    ) / filled
                    print(
                        'Order {} with side {} has price {} (filled {})'.format(
                        order_id, state['orderParameters']['side'], avg_price, 
                        filled
                    ))
            # ждать до следующей проверки
            time.sleep(self.__interval)

    # добавить/удалить заявку из watchdog
    def add_order(self, order_id: str) -> None:
        with self.__lock:
            if order_id in self.__orders:
                return
            self.__orders[order_id] = dict()

    def remove_order(self, order_id: str) -> None:
        with self.__lock:
            try:
                del self.__orders[order_id]
            except KeyError:
                pass

Реализация стратегии


Как вы могли заметить, мы так и не дошли до самого интересного, а именно до реализации нашей торговой стратегии. Выглядеть она будет примерно так:


class GridBrokerWorker(object):
    def __init__(self, account: str, interval: str, application: str, token: str):
        self.__account = account
        self.__interval = interval
        # объект с авторизацией
        self.__auth = requests.auth.HTTPBasicAuth(application, token)

        # создадим брокер-адаптер и сразу его запустим
        self.__broker = broker_adapter.BrokerAdapter(
            self.__account, self.__interval, self.__auth)
        self.__broker.start()

    def run(self, instrument, quantity, grid) -> None:
        # здесь мы создадим адаптер для фида и подпишемся на его обновления
        feed = feed_adapter.FeedAdapter(instrument, self.__auth)
        old_mid = None
        for quote in feed.run():
            mid = (quote['bid'] + quote['ask']) / 2
            # если это первая котировка, то не делаем ничего
            if old_mid is None:
                old_mid = mid
                continue
            # если не первая, то прищуриваемся и проверяем не больше ли изменение
            # цены, чем шаг
            if abs(old_mid - mid) < grid:
                continue
            # проставляем цену в зависимости от того, в какую сторону изменилась цена
            side = ‘sell’ if mid - old_mid > 0 else ‘buy’
            # ставим заявку
            order_id = self.__broker.place_limit(
                instrument, side, str(quantity), str(mid))

            # обрабатываем результат
            if not order_id:
                print('Unexpected error')
                continue
            # читаемая ошибка
            elif not isinstance(order_id, str):
                print('Unexpected error: {}'.format(order_id))
                continue
            # заявка поставилась! Добавляем ее к watchdog...
            self.__broker.add_order(order_id)
            # ...и обновляем уровень цены
            old_mid = mid

Запуск и отладка


# создадим экземпляр класса
worker = GridBrokerWorker('ABC1234.001', 60, 'appid', 'token')
# запустим
worker.run('LTC.EXANTE', 100, 0.1)

В дальнейшем, для того чтобы робот вообще смог торговать, мы крутим параметр grid в соответствии с колебанием рынка для выбранного финансового инструмента. Также следует отметить, что данная стратегия редко используется для чего-либо отличного от форекса. Тем не менее наш робот готов.


Известные проблемы


  • Робот довольно тупой и не умеет ничего делать, кроме как торговать по одной стратегии с фиксированными заранее параметрами...
  • … и может делать это плохо и падать с исключениями...
  • … а когда не сломается, будет работать неспешно.
  • Есть проблема с представлением чисел в типе double. Тут поможет замена double на Decimal.
  • Нет расчета величин важных для трейдера, например, PnL.

Вместо заключения


Ряд проблем мы постарались учесть в нашем репозитории на GitHub, посвященном данному примеру. Код в репозитории местами задокументирован и опубликован под лицензией MIT. Ниже также представлено небольшое видео с демонстрацией работы нашего робота:


Проголосовать:
+5
Сохранить: