Написание торговых роботов, как правило, достаточно трудоемкая задача — помимо понимания принципов торговли (равно как и представления о том, как та или иная стратегия выглядит), необходимо знать и уметь работать с протоколами, используемыми для торговли. Вкратце — существуют две основные группы протоколов, которые предоставляются биржей или брокерами: FIX, в котором без бутылки не разобраться, и проприетарный бинарный протокол, который редко бывает лучше. Это приводит к одной из двух проблем: либо код выглядит так, что любой джуниор схватится за голову, либо хороший, красивый код, который умеет делать примерно ничего (а то, что умеет, делает с разными неожиданными проблемами).
Для того чтобы решить обозначенные выше проблемы и привлечь как можно больше участников, брокеры иногда представляют обычное HTTP API с сериализацией в json/xml/что-то более экзотическое. В частности, подобный метод общения с биржей является едва ли не единственным для ряда модных стартапов, например, биткоин-бирж. Мы решили не отставать от них и недавно представили дополнение к нашему API (подробнее про его старые возможности можно почитать на Хабре здесь и здесь), которое позволяет пользователю также и торговать.
Под катом не совсем пятничная статья-туториал про то, как можно было бы торговать через наше HTTP API.
Реализовывать мы будем робота, который торгует по grid-стратегии. Выглядит она следующим образом:
- Выберем шаг цены (сетки)
step
и количество одной заявкиsize
. - Сохраняем текущую цену.
- Получаем новую цену и сравним с сохраненной.
- Если цена изменилась меньше чем на
step
, то вернуться к п.3. - Если цена изменилась больше чем на
step
, то:
a. Если цена увеличилась, то ставим заявку с количествомsize
на продажу.
b. Если уменьшилась — то на покупку с таким же количеством. - Вернуться к п.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. Ниже также представлено небольшое видео с демонстрацией работы нашего робота:
Автор: arcan1s