├── __init__.py ├── README.md ├── TKStore.py ├── Examples └── LimitCancel.py ├── TKBroker.py └── TKData.py /__init__.py: -------------------------------------------------------------------------------- 1 | from .TKStore import * 2 | from .TKData import * # Также подключает данные в хранилище 3 | from .TKBroker import * # Также подключает брокера в хранилище 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BackTraderTinkoff 2 | Провайдер для автоторговли в [BackTrader](https://backtrader.com/) из [Tinkoff Invest API](https://tinkoff.github.io/investAPI/). Использует библиотеку [TinkoffPy](https://github.com/cia76/TinkoffPy). Для получения новых бар по расписанию потребуется библиотека [MarketPy](https://github.com/cia76/MarketPy). 3 | 4 | ### Для чего нужен 5 | Чтобы торговые системы, написанные для BackTrader, можно было поставить на автоматическую торговлю с брокером Тинькофф Инвестиции. 6 | 7 | ### Установка провайдера 8 | 1. Скопируйте файлы проекта в папку с торговой системой BackTrader 9 | 10 | ### Начало работы 11 | В папке **Examples** находится хорошо документированный код примера **LimitCancel.py**. В нем подробно объяснены: 12 | 1. Формат обращения к тикерам, в т.ч. фьючерсам 13 | 2. Настройка Cerebro без отображения статистики и получения событий без задержек 14 | 3. Включение системы ведения логов с выводом на консоль и в файл 15 | 4. Конфигурация брокера 16 | 5. Конфигурация исторических и новых бар 17 | 6. Размер позиции для акций и фьючерсов 18 | 7. Торговая система с параметрами 19 | 8. Обработка получения нового бара 20 | 9. Обработака статуса торговли 21 | 10. Обработка исполнения заявки 22 | 11. Обработка изменения статуса позиции 23 | 24 | ### Авторство, право использования, развитие 25 | Автор данной библиотеки Чечет Игорь Александрович. 26 | 27 | Библиотека написана в рамках проекта [Финансовая Лаборатория](https://finlab.vip/) и предоставляется бесплатно. При распространении ссылка на автора и проект обязательны. 28 | 29 | Исправление ошибок, доработка и развитие библиотеки осуществляется автором и сообществом проекта [Финансовая Лаборатория](https://finlab.vip/). 30 | ### Что дальше 31 | - Бесплатный курс "Автоторговля" по идеям, концепциям и процессам алгоритмической/автоматической торговли [смотрите здесь >>>](https://finlab.vip/wpm-category/autotrading2021/) 32 | 33 | 34 | - Бесплатный курс "BackTrader: Быстрый старт" [ждет вас здесь >>>](https://finlab.vip/wpm-category/btquikstart/) 35 | 36 | 37 | - [Подписывайтесь на Telegram канал "Финансовой Лаборатории",](https://t.me/finlabvip) чтобы быть в курсе всех новинок алгоритмической и автоматической торговли. -------------------------------------------------------------------------------- /TKStore.py: -------------------------------------------------------------------------------- 1 | from collections import deque 2 | from datetime import datetime 3 | from threading import Thread 4 | import logging 5 | 6 | from backtrader.metabase import MetaParams 7 | from backtrader.utils.py3 import with_metaclass 8 | 9 | from TinkoffPy import TinkoffPy 10 | from TinkoffPy.grpc.marketdata_pb2 import Candle, SubscriptionInterval 11 | 12 | 13 | class MetaSingleton(MetaParams): 14 | """Метакласс для создания Singleton классов""" 15 | def __init__(cls, *args, **kwargs): 16 | """Инициализация класса""" 17 | super(MetaSingleton, cls).__init__(*args, **kwargs) 18 | cls._singleton = None # Экземпляра класса еще нет 19 | 20 | def __call__(cls, *args, **kwargs): 21 | """Вызов класса""" 22 | if cls._singleton is None: # Если класса нет в экземплярах класса 23 | cls._singleton = super(MetaSingleton, cls).__call__(*args, **kwargs) # то создаем зкземпляр класса 24 | return cls._singleton # Возвращаем экземпляр класса 25 | 26 | 27 | class TKStore(with_metaclass(MetaSingleton, object)): 28 | """Хранилище Тинькофф""" 29 | logger = logging.getLogger('TKStore') # Будем вести лог 30 | 31 | BrokerCls = None # Класс брокера будет задан из брокера 32 | DataCls = None # Класс данных будет задан из данных 33 | 34 | @classmethod 35 | def getdata(cls, *args, **kwargs): 36 | """Возвращает новый экземпляр класса данных с заданными параметрами""" 37 | return cls.DataCls(*args, **kwargs) 38 | 39 | @classmethod 40 | def getbroker(cls, *args, **kwargs): 41 | """Возвращает новый экземпляр класса брокера с заданными параметрами""" 42 | return cls.BrokerCls(*args, **kwargs) 43 | 44 | def __init__(self, provider=TinkoffPy()): 45 | super(TKStore, self).__init__() 46 | self.notifs = deque() # Уведомления хранилища 47 | self.provider = provider # Подключаемся ко всем торговым счетам 48 | self.new_bars = [] # Новые бары по всем подпискам на тикеры из Тинькофф 49 | 50 | def start(self): 51 | self.provider.on_candle = self.on_candle # Обработчик новых баров по подписке из Тинькофф 52 | Thread(target=self.provider.subscriptions_marketdata_handler, name='SubscriptionsMarketdataThread').start() # Создаем и запускаем поток обработки подписок на биржевую информацию 53 | 54 | def put_notification(self, msg, *args, **kwargs): 55 | self.notifs.append((msg, args, kwargs)) 56 | 57 | def get_notifications(self): 58 | """Выдача уведомлений хранилища""" 59 | self.notifs.append(None) 60 | return [x for x in iter(self.notifs.popleft, None)] 61 | 62 | def stop(self): 63 | self.provider.on_candle = self.provider.default_handler # Возвращаем обработчик по умолчанию 64 | self.provider.close_channel() # Закрываем канал перед выходом 65 | 66 | def on_candle(self, candle: Candle): 67 | """Обработка прихода нового бара""" 68 | tf = 'M1' if candle.interval == SubscriptionInterval.SUBSCRIPTION_INTERVAL_ONE_MINUTE else\ 69 | 'M5' if candle.interval == SubscriptionInterval.SUBSCRIPTION_INTERVAL_FIVE_MINUTES else None # Т.к. для баров и подписок используются разные временнЫе интервалы, то используем временнОй интервал из расписания 70 | bar = dict(datetime=self.provider.utc_to_msk_datetime(datetime.utcfromtimestamp(candle.time.seconds)), # Дату/время переводим из UTC в МСК 71 | open=self.provider.quotation_to_float(candle.open), 72 | high=self.provider.quotation_to_float(candle.high), 73 | low=self.provider.quotation_to_float(candle.low), 74 | close=self.provider.quotation_to_float(candle.close), 75 | volume=int(candle.volume)) 76 | self.new_bars.append(dict(guid=(candle.figi, tf), data=bar)) 77 | -------------------------------------------------------------------------------- /Examples/LimitCancel.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime, timedelta 3 | 4 | import backtrader as bt 5 | 6 | from BackTraderTinkoff import TKStore # Хранилище Tinkoff 7 | from MarketPy.Schedule import MOEXStocks, MOEXFutures # Расписания торгов фондового/срочного рынков 8 | 9 | 10 | # noinspection PyShadowingNames,PyProtectedMember 11 | class LimitCancel(bt.Strategy): 12 | """ 13 | Выставляем заявку на покупку на n% ниже цены закрытия 14 | Если за 1 бар заявка не срабатывает, то закрываем ее 15 | Если срабатывает, то закрываем позицию. Неважно, с прибылью или убытком 16 | """ 17 | logger = logging.getLogger('BackTraderTinkoff.LimitCancel') # Будем вести лог 18 | params = ( # Параметры торговой системы 19 | ('limit_pct', 1), # Заявка на покупку на n% ниже цены закрытия 20 | ) 21 | 22 | def __init__(self): 23 | """Инициализация торговой системы""" 24 | self.live = False # Сначала будут приходить исторические данные, затем перейдем в режим реальной торговли 25 | self.order = None # Заявка на вход/выход из позиции 26 | 27 | def next(self): 28 | """Получение следующего исторического/нового бара""" 29 | if not self.live: # Если не в режиме реальной торговли 30 | return # то выходим, дальше не продолжаем 31 | self.logger.info(f'Получен бар: {self.data._name} - {bt.TimeFrame.Names[self.data.p.timeframe]} {self.data.p.compression} - {bt.num2date(self.data.datetime[0]):%d.%m.%Y %H:%M:%S} - Open = {self.data.open[0]}, High = {self.data.high[0]}, Low = {self.data.low[0]}, Close = {self.data.close[0]}, Volume = {self.data.volume[0]}') 32 | if self.order and self.order.status == bt.Order.Submitted: # Если заявка не исполнена (отправлена брокеру) 33 | return # то ждем исполнения, выходим, дальше не продолжаем 34 | if not self.position: # Если позиции нет 35 | if self.order and self.order.status == bt.Order.Accepted: # Если заявка не исполнена (принята брокером) 36 | self.cancel(self.order) # то снимаем ее 37 | limit_price = self.data.close[0] * (1 - self.p.limit_pct / 100) # На n% ниже цены закрытия 38 | self.order = self.buy(exectype=bt.Order.Limit, price=limit_price) # Лимитная заявка на покупку 39 | self.logger.info(f'Заявка {self.order.ref} - {"Покупка" if self.order.isbuy else "Продажа"} {self.order.data._name} {self.order.size} @ {self.order.price} cоздана и отправлена на биржу') 40 | else: # Если позиция есть 41 | self.order = self.close() # Заявка на закрытие позиции (заявки) по рыночной цене 42 | 43 | def notify_data(self, data, status, *args, **kwargs): 44 | """Изменение статуса приходящих баров""" 45 | data_status = data._getstatusname(status) # Получаем статус (только при live_bars=True) 46 | self.live = data_status == 'LIVE' # Режим реальной торговли 47 | self.logger.info(data_status) 48 | 49 | def notify_order(self, order): 50 | """Изменение статуса заявки""" 51 | if order.status in (bt.Order.Created, bt.Order.Submitted, bt.Order.Accepted): # Если заявка создана, отправлена брокеру, принята брокером (не исполнена) 52 | self.logger.info(f'Заявка {order.ref} со статусом {order.getstatusname()}') 53 | elif order.status in (bt.Order.Canceled, bt.Order.Margin, bt.Order.Rejected, bt.Order.Expired): # Если заявка отменена, нет средств, заявка отклонена брокером, снята по времени (снята) 54 | self.logger.info(f'Заявка {order.ref} отменена со статусом {order.getstatusname()}') 55 | elif order.status == bt.Order.Partial: # Если заявка частично исполнена 56 | self.logger.info(f'Заявка {order.ref} частично исполнена со статусом {order.getstatusname()}') 57 | elif order.status == bt.Order.Completed: # Если заявка полностью исполнена 58 | self.logger.info(f'Заявка на {"покупку" if order.isbuy() else "продажу"} исполнена по цене {order.executed.price}, Стоимость {order.executed.value}, Комиссия {order.executed.comm}') 59 | self.order = None # Сбрасываем заявку на вход в позицию 60 | 61 | def notify_trade(self, trade): 62 | """Изменение статуса позиции""" 63 | if trade.isclosed: # Если позиция закрыта 64 | self.logger.info(f'Позиция закрыта. Прибыль = {trade.pnl:.2f}, С учетом комиссий = {trade.pnlcomm:.2f}') 65 | 66 | 67 | if __name__ == '__main__': # Точка входа при запуске этого скрипта 68 | symbol = 'TQBR.SBER' # Тикер в формате: <Код режима торгов>.<Тикер> 69 | # symbol = 'SPBFUT.SiH4' # Для фьючерсов: .<Код тикера заглавными буквами>-<Месяц экспирации: 3, 6, 9, 12>.<Последние 2 цифры года> 70 | # symbol = 'SPBFUT.RIH4' 71 | timeframe = bt.TimeFrame.Minutes # Минутный временной интервал 72 | compression = 1 # 1 минута 73 | fromdate = datetime.today().date() # За сегодня 74 | live_bars = True # Исторические и новые бары 75 | # schedule = MOEXStocks() # Расписание торгов фондового рынка 76 | # schedule.delta = timedelta(seconds=10) # Для расписания нужно увеличить время ожидания, т.к. новый бар у Tinkoff не успевает формироваться 77 | # schedule = MOEXFutures() # Расписание торгов срочного рынка 78 | # schedule.delta = timedelta(seconds=10) # Для расписания нужно увеличить время ожидания, т.к. новый бар у Tinkoff не успевает формироваться 79 | 80 | logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', # Формат сообщения 81 | datefmt='%d.%m.%Y %H:%M:%S', # Формат даты 82 | level=logging.DEBUG, # Уровень логируемых событий NOTSET/DEBUG/INFO/WARNING/ERROR/CRITICAL 83 | handlers=[logging.FileHandler('LimitCancel.log'), logging.StreamHandler()]) # Лог записываем в файл и выводим на консоль 84 | logging.Formatter.converter = lambda *args: datetime.now(tz=store.provider.tz_msk).timetuple() # В логе время указываем по МСК 85 | 86 | # noinspection PyArgumentList 87 | cerebro = bt.Cerebro(stdstats=False, quicknotify=True) # Инициируем "движок" BackTrader. Стандартная статистика сделок и кривой доходности не нужна. События принимаем без задержек 88 | store = TKStore() # Хранилище Tinkoff 89 | broker = store.getbroker() # Брокер Tinkoff 90 | # noinspection PyArgumentList 91 | cerebro.setbroker(broker) # Устанавливаем брокера 92 | data = store.getdata(dataname=symbol, timeframe=timeframe, compression=compression, fromdate=fromdate, live_bars=live_bars) # Исторические и новые минутные бары за сегодня по подписке. 1 и 5 минут. Остальное по расписанию 93 | # data = store.getdata(dataname=symbol, timeframe=timeframe, compression=compression, fromdate=fromdate, schedule=schedule, live_bars=live_bars) # Исторические и новые минутные бары за сегодня по расписанию 94 | cerebro.adddata(data) # Добавляем данные 95 | cerebro.addsizer(bt.sizers.FixedSize, stake=10) # Кол-во акций в штуках для покупки/продажи 96 | # cerebro.addsizer(bt.sizers.FixedSize, stake=1) # Кол-во фьючерсов в штуках для покупки/продажи 97 | cerebro.addstrategy(LimitCancel, limit_pct=1) # Добавляем торговую систему с лимитным входом в n% 98 | cerebro.run() # Запуск торговой системы 99 | -------------------------------------------------------------------------------- /TKBroker.py: -------------------------------------------------------------------------------- 1 | from typing import Union # Объединение типов 2 | import collections 3 | from uuid import uuid4 # Номера заявок должны быть уникальными во времени и пространстве 4 | from threading import Thread 5 | import logging 6 | 7 | from backtrader import BrokerBase, Order, BuyOrder, SellOrder 8 | from backtrader.position import Position 9 | from backtrader.utils.py3 import with_metaclass 10 | 11 | from BackTraderTinkoff import TKStore, TKData 12 | 13 | from TinkoffPy.grpc.operations_pb2 import PortfolioRequest, PortfolioResponse # Портфель 14 | from TinkoffPy.grpc.orders_pb2 import ( 15 | PostOrderRequest, CancelOrderRequest, ORDER_DIRECTION_BUY, ORDER_DIRECTION_SELL, ORDER_TYPE_MARKET, ORDER_TYPE_LIMIT, OrderTrades) # Заявка 16 | from TinkoffPy.grpc.stoporders_pb2 import ( 17 | PostStopOrderRequest, CancelStopOrderRequest, STOP_ORDER_DIRECTION_BUY, STOP_ORDER_DIRECTION_SELL, StopOrderExpirationType, StopOrderType) # Стоп-заявка 18 | 19 | 20 | # noinspection PyArgumentList 21 | class MetaTKBroker(BrokerBase.__class__): 22 | def __init__(self, name, bases, dct): 23 | super(MetaTKBroker, self).__init__(name, bases, dct) # Инициализируем класс брокера 24 | TKStore.BrokerCls = self # Регистрируем класс брокера в хранилище Tinkoff 25 | 26 | 27 | # noinspection PyProtectedMember,PyArgumentList,PyUnusedLocal 28 | class TKBroker(with_metaclass(MetaTKBroker, BrokerBase)): 29 | """Брокер Tinkoff""" 30 | logger = logging.getLogger('TKBroker') # Будем вести лог 31 | currency = PortfolioRequest.CurrencyRequest.RUB # Суммы будем получать в российских рублях 32 | 33 | def __init__(self, **kwargs): 34 | super(TKBroker, self).__init__() 35 | self.store = TKStore(**kwargs) # Хранилище Tinkoff 36 | self.notifs = collections.deque() # Очередь уведомлений брокера о заявках 37 | self.startingcash = self.cash = 0 # Стартовые и текущие свободные средства по счету 38 | self.startingvalue = self.value = 0 # Стартовая и текущая стоимость позиций 39 | self.positions = collections.defaultdict(Position) # Список позиций 40 | self.orders = collections.OrderedDict() # Список заявок, отправленных на биржу 41 | self.ocos = {} # Список связанных заявок (One Cancel Others) 42 | self.pcs = collections.defaultdict(collections.deque) # Очередь всех родительских/дочерних заявок (Parent - Children) 43 | 44 | self.store.provider.on_order_trades = self.on_order_trades # Обработка сделок по заявке 45 | Thread(target=self.store.provider.subscriptions_trades_handler, name='SubscriptionsTradesThread', args=[accounts.id for accounts in self.store.provider.accounts]).start() # Создаем и запускаем поток обработки подписок сделок по заявке 46 | 47 | def start(self): 48 | super(TKBroker, self).start() 49 | self.get_all_active_positions() # Получаем все активные позиции 50 | 51 | def getcash(self, account=None): 52 | """Свободные средства по счету, по всем счетам""" 53 | cash = 0 # Будем набирать свободные средства 54 | if self.store.BrokerCls: # Если брокер есть в хранилище 55 | if account: # Если считаем свободные средства по счету 56 | cash = next((position.price for key, position in self.positions.items() if key[0] == account and not key[1]), None) # Денежная позиция по портфелю/рынку 57 | else: # Если считаем свободные средства по всем счетам 58 | cash = sum([position.price for key, position in self.positions.items() if not key[1]]) # Сумма всех денежных позиций 59 | self.cash = cash # Сохраняем текущие свободные средства 60 | return self.cash 61 | 62 | def getvalue(self, datas=None, account=None): 63 | """Стоимость позиции, позиций по счету, всех позиций""" 64 | value = 0 # Будем набирать стоимость позиций 65 | if self.store.BrokerCls: # Если брокер есть в хранилище 66 | if datas: # Если считаем стоимость позиции/позиций 67 | data: TKData # Данные Финам 68 | for data in datas: # Пробегаемся по всем тикерам 69 | position = self.positions[(data.client_id, data.board, data.symbol)] # Позиция по тикеру 70 | value += position.price * position.size # Добавляем стоимость позиции по тикеру 71 | elif account: # Если считаем свободные средства по счету 72 | value = sum([position.price * position.size for key, position in self.positions.items() if key[0] == account and key[1]]) # Стоимость позиций по портфелю/бирже 73 | else: # Если считаем стоимость всех позиций 74 | value = sum([position.price * position.size for key, position in self.positions.items() if key[1]]) # Стоимость всех позиций 75 | self.value = value # Сохраняем текущую стоимость позиций 76 | return value 77 | 78 | def getposition(self, data: TKData): 79 | """Позиция по тикеру 80 | Используется в strategy.py для закрытия (close) и ребалансировки (увеличения/уменьшения) позиции: 81 | - В процентах от портфеля (order_target_percent) 82 | - До нужного кол-ва (order_target_size) 83 | - До нужного объема (order_target_value) 84 | """ 85 | return self.positions[(data.account.id, data.class_code, data.symbol)] # Получаем позицию по тикеру или нулевую позицию, если тикера в списке позиций нет 86 | 87 | def buy(self, owner, data, size, price=None, plimit=None, exectype=None, valid=None, tradeid=0, oco=None, trailamount=None, trailpercent=None, parent=None, transmit=True, **kwargs): 88 | """Заявка на покупку""" 89 | order = self.create_order(owner, data, size, price, plimit, exectype, valid, oco, parent, transmit, True, **kwargs) 90 | self.notifs.append(order.clone()) # Уведомляем брокера о принятии/отклонении зявки на бирже 91 | return order 92 | 93 | def sell(self, owner, data, size, price=None, plimit=None, exectype=None, valid=None, tradeid=0, oco=None, trailamount=None, trailpercent=None, parent=None, transmit=True, **kwargs): 94 | """Заявка на продажу""" 95 | order = self.create_order(owner, data, size, price, plimit, exectype, valid, oco, parent, transmit, False, **kwargs) 96 | self.notifs.append(order.clone()) # Уведомляем брокера о принятии/отклонении зявки на бирже 97 | return order 98 | 99 | def cancel(self, order): 100 | """Отмена заявки""" 101 | return self.cancel_order(order) 102 | 103 | def get_notification(self): 104 | return self.notifs.popleft() if self.notifs else None # Удаляем и возвращаем крайний левый элемент списка уведомлений или ничего 105 | 106 | def next(self): 107 | self.notifs.append(None) # Добавляем в список уведомлений пустой элемент 108 | 109 | def stop(self): 110 | super(TKBroker, self).stop() 111 | self.store.provider.on_order_trades = self.store.provider.default_handler # Обработка сделок по заявке 112 | self.store.BrokerCls = None # Удаляем класс брокера из хранилища 113 | 114 | # Функции 115 | 116 | def get_all_active_positions(self): 117 | """Все активные позиции по счету""" 118 | cash = 0 # Будем набирать свободные средства 119 | value = 0 # Будем набирать стоимость позиций 120 | for account in self.store.provider.accounts: 121 | request = PortfolioRequest(account_id=account.id, currency=self.currency) # Запрос портфеля по счету в рублях 122 | response: PortfolioResponse = self.store.provider.call_function(self.store.provider.stub_operations.GetPortfolio, request) # Портфель по счету 123 | cash += self.store.provider.money_value_to_float(response.total_amount_currencies, self.currency) # Увеличиваем общий размер свободных средств 124 | for position in response.positions: # Пробегаемся по всем активным позициям счета 125 | si = self.store.provider.figi_to_symbol_info(position.figi) # Поиск тикера по уникальному коду 126 | size = self.store.provider.quotation_to_float(position.quantity) # Кол-во в штуках 127 | price = self.store.provider.money_value_to_float(position.average_position_price) # Цена входа 128 | value += price * size # Увеличиваем общий размер стоимости позиций 129 | self.positions[(account.id, si.class_code, si.ticker)] = Position(size, price) # Сохраняем в списке открытых позиций 130 | self.cash = cash # Сохраняем текущие свободные средства 131 | self.value = value # Сохраняем текущую стоимость позиций 132 | 133 | def get_order(self, order_id: str) -> Union[Order, None]: 134 | """Заявка BackTrader по номеру заявки на бирже 135 | Пробегаемся по всем заявкам на бирже. Если нашли совпадение с номером заявки на бирже, то возвращаем заявку BackTrader. Иначе, ничего не найдено 136 | 137 | :param str order_id: Номер заявки на бирже 138 | :return: Заявка BackTrader или None 139 | """ 140 | return next((order for order in self.orders.values() if order.info['order_id'] == order_id), None) 141 | 142 | def create_order(self, owner, data: TKData, size, price=None, plimit=None, exectype=None, valid=None, oco=None, parent=None, transmit=True, is_buy=True, **kwargs): 143 | """Создание заявки. Привязка параметров счета и тикера. Обработка связанных и родительской/дочерних заявок 144 | Даполнительные параметры передаются через **kwargs: 145 | - account_id - Порядковый номер счета 146 | """ 147 | order = BuyOrder(owner=owner, data=data, size=size, price=price, pricelimit=plimit, exectype=exectype, valid=valid, oco=oco, parent=parent, transmit=transmit) if is_buy \ 148 | else SellOrder(owner=owner, data=data, size=size, price=price, pricelimit=plimit, exectype=exectype, valid=valid, oco=oco, parent=parent, transmit=transmit) # Заявка на покупку/продажу 149 | order.addcomminfo(self.getcommissioninfo(data)) # По тикеру выставляем комиссии в заявку. Нужно для исполнения заявки в BackTrader 150 | order.addinfo(**kwargs) # Передаем в заявку все дополнительные параметры, в т.ч. account_id 151 | if order.exectype in (Order.Close, Order.StopTrail, Order.StopTrailLimit, Order.Historical): # Эти типы заявок не реализованы 152 | print(f'Постановка заявки {order.ref} по тикеру {data.class_code}.{data.symbol} отклонена. Работа с заявками {order.exectype} не реализована') 153 | order.reject(self) # то отклоняем заявку 154 | self.oco_pc_check(order) # Проверяем связанные и родительскую/дочерние заявки 155 | return order # Возвращаем отклоненную заявку 156 | account = self.store.provider.accounts[order.info['account_id']] if 'account_id' in order.info else data.account # Торговый счет из заявки/тикера 157 | order.addinfo(account=account.id) # Сохраняем в заявке 158 | if order.exectype != Order.Market and not order.price: # Если цена заявки не указана для всех заявок, кроме рыночной 159 | price_type = 'Лимитная' if order.exectype == Order.Limit else 'Стоп' # Для стоп заявок это будет триггерная (стоп) цена 160 | print(f'Постановка заявки {order.ref} по тикеру {data.class_code}.{data.symbol} отклонена. {price_type} цена (price) не указана для заявки типа {order.exectype}') 161 | order.reject(self) # то отклоняем заявку 162 | self.oco_pc_check(order) # Проверяем связанные и родительскую/дочерние заявки 163 | return order # Возвращаем отклоненную заявку 164 | if order.exectype == Order.StopLimit and not order.pricelimit: # Если лимитная цена не указана для стоп-лимитной заявки 165 | print(f'Постановка заявки {order.ref} по тикеру {data.class_code}.{data.symbol} отклонена. Лимитная цена (pricelimit) не указана для заявки типа {order.exectype}') 166 | order.reject(self) # то отклоняем заявку 167 | self.oco_pc_check(order) # Проверяем связанные и родительскую/дочерние заявки 168 | return order # Возвращаем отклоненную заявку 169 | if oco: # Если есть связанная заявка 170 | self.ocos[order.ref] = oco.ref # то заносим в список связанных заявок 171 | if not transmit or parent: # Для родительской/дочерних заявок 172 | parent_ref = getattr(order.parent, 'ref', order.ref) # Номер транзакции родительской заявки или номер заявки, если родительской заявки нет 173 | if order.ref != parent_ref and parent_ref not in self.pcs: # Если есть родительская заявка, но она не найдена в очереди родительских/дочерних заявок 174 | print(f'Постановка заявки {order.ref} по тикеру {data.class_code}.{data.symbol} отклонена. Родительская заявка не найдена') 175 | order.reject(self) # то отклоняем заявку 176 | self.oco_pc_check(order) # Проверяем связанные и родительскую/дочерние заявки 177 | return order # Возвращаем отклоненную заявку 178 | pcs = self.pcs[parent_ref] # В очередь к родительской заявке 179 | pcs.append(order) # добавляем заявку (родительскую или дочернюю) 180 | if transmit: # Если обычная заявка или последняя дочерняя заявка 181 | if not parent: # Для обычных заявок 182 | return self.place_order(order) # Отправляем заявку на биржу 183 | else: # Если последняя заявка в цепочке родительской/дочерних заявок 184 | self.notifs.append(order.clone()) # Удедомляем брокера о создании новой заявки 185 | return self.place_order(order.parent) # Отправляем родительскую заявку на биржу 186 | # Если не последняя заявка в цепочке родительской/дочерних заявок (transmit=False) 187 | return order # то возвращаем созданную заявку со статусом Created. На биржу ее пока не ставим 188 | 189 | def place_order(self, order: Order): 190 | """Отправка заявки на биржу""" 191 | account = order.info['account'] # Торговый счет 192 | class_code = order.data.class_code # Код режима торгов 193 | symbol = order.data.symbol # Тикер 194 | si = self.store.provider.get_symbol_info(class_code, symbol) # Поиск тикера по коду площадки/названию 195 | quantity: int = abs(order.size // si.lot) # Размер позиции в лотах. В Тинькофф всегда передается положительный размер лота 196 | order_id = str(uuid4()) # Уникальный идентификатор заявки 197 | response = None # Результат запроса 198 | if order.exectype == Order.Market: # Рыночная заявка 199 | direction = ORDER_DIRECTION_BUY if order.isbuy() else ORDER_DIRECTION_SELL # Покупка/продажа 200 | request = PostOrderRequest(instrument_id=si.figi, quantity=quantity, direction=direction, account_id=account, order_type=ORDER_TYPE_MARKET, order_id=order_id) 201 | response = self.store.provider.call_function(self.store.provider.stub_orders.PostOrder, request) 202 | elif order.exectype == Order.Limit: # Лимитная заявка 203 | direction = ORDER_DIRECTION_BUY if order.isbuy() else ORDER_DIRECTION_SELL # Покупка/продажа 204 | price = self.store.provider.float_to_quotation(self.store.provider.price_to_tinkoff_price(class_code, symbol, order.price)) # Лимитная цена 205 | request = PostOrderRequest(instrument_id=si.figi, quantity=quantity, price=price, direction=direction, account_id=account, order_type=ORDER_TYPE_LIMIT, order_id=order_id) 206 | response = self.store.provider.call_function(self.store.provider.stub_orders.PostOrder, request) 207 | elif order.exectype == Order.Stop: # Стоп заявка 208 | direction = STOP_ORDER_DIRECTION_BUY if order.isbuy() else STOP_ORDER_DIRECTION_SELL # Покупка/продажа 209 | price = self.store.provider.float_to_quotation(self.store.provider.price_to_tinkoff_price(class_code, symbol, order.price)) # Стоп цена 210 | request = PostStopOrderRequest(instrument_id=si.figi, quantity=quantity, stop_price=price, direction=direction, account_id=account, 211 | expiration_type=StopOrderExpirationType.STOP_ORDER_EXPIRATION_TYPE_GOOD_TILL_CANCEL, stop_order_type=StopOrderType.STOP_ORDER_TYPE_STOP_LOSS) 212 | response = self.store.provider.call_function(self.store.provider.stub_stop_orders.PostStopOrder, request) 213 | elif order.exectype == Order.StopLimit: # Стоп-лимитная заявка 214 | direction = STOP_ORDER_DIRECTION_BUY if order.isbuy() else STOP_ORDER_DIRECTION_SELL # Покупка/продажа 215 | price = self.store.provider.float_to_quotation(self.store.provider.price_to_tinkoff_price(class_code, symbol, order.price)) # Стоп цена 216 | pricelimit = self.store.provider.float_to_quotation(self.store.provider.price_to_tinkoff_price(class_code, symbol, order.pricelimit)) # Лимитная цена 217 | request = PostStopOrderRequest(instrument_id=si.figi, quantity=quantity, stop_price=price, price=pricelimit, direction=direction, account_id=account, 218 | expiration_type=StopOrderExpirationType.STOP_ORDER_EXPIRATION_TYPE_GOOD_TILL_CANCEL, stop_order_type=StopOrderType.STOP_ORDER_TYPE_STOP_LIMIT) 219 | response = self.store.provider.call_function(self.store.provider.stub_stop_orders.PostStopOrder, request) 220 | order.submit(self) # Отправляем заявку на биржу (Order.Submitted) 221 | self.notifs.append(order.clone()) # Уведомляем брокера об отправке заявки на биржу 222 | if not response: # Если при отправке заявки на биржу произошла веб ошибка 223 | self.logger.warning(f'Постановка заявки по тикеру {class_code}.{symbol} отклонена. Ошибка веб сервиса') 224 | order.reject(self) # то отклоняем заявку 225 | self.oco_pc_check(order) # Проверяем связанные и родительскую/дочерние заявки 226 | return order # Возвращаем отклоненную заявку 227 | if order.exectype in (Order.Market, Order.Limit): # Для рыночной и лимитной заявки 228 | order.addinfo(order_id=response.order_id) # Номер заявки добавляем в заявку 229 | elif order.exectype in (Order.Stop, Order.StopLimit): # Для стоп и стоп-лимитной заявки 230 | order.addinfo(stop_order_id=response.stop_order_id) # Уникальный идентификатор стоп-заявки добавляем в заявку 231 | order.accept(self) # Заявка принята на бирже (Order.Accepted) 232 | self.orders[order.ref] = order # Сохраняем заявку в списке заявок, отправленных на биржу 233 | return order # Возвращаем заявку 234 | 235 | def cancel_order(self, order): 236 | """Отмена заявки""" 237 | # TODO Ждем от Тинькофф подписку на изменение статуса заявки. Пока нужно снимать заявки руками до окончания торговой сессии 238 | if not order.alive(): # Если заявка уже была завершена 239 | return # то выходим, дальше не продолжаем 240 | account = order.info['account'] # Торговый счет 241 | if order.exectype in (Order.Market, Order.Limit): # Для рыночной и лимитной заявки 242 | request = CancelOrderRequest(account_id=account, order_id=order.info['order_id']) # Отмена активной заявки 243 | response = self.store.provider.call_function(self.store.provider.stub_orders.CancelOrder, request) 244 | if response: # TODO Ждем от Тинькофф подписку на изменение статуса заявки. Отменять будем не по ответу брокера, а по приходу статуса 245 | order.cancel() # Отменяем существующую заявку 246 | self.notifs.append(order.clone()) # Уведомляем брокера об отмене заявки 247 | self.oco_pc_check(order) # Проверяем связанные и родительскую/дочерние заявки (Canceled) 248 | elif order.exectype in (Order.Stop, Order.StopLimit): # Для стоп и стоп-лимитной заявки 249 | request = CancelStopOrderRequest(account_id=account, stop_order_id=order.info['stop_order_id']) # Отмена активной стоп заявки 250 | response = self.store.provider.call_function(self.store.provider.stub_stop_orders.CancelStopOrder, request) 251 | if response: # TODO Ждем от Тинькофф подписку на изменение статуса заявки. Отменять будем не по ответу брокера, а по приходу статуса 252 | order.cancel() # Отменяем существующую заявку 253 | self.notifs.append(order.clone()) # Уведомляем брокера об отмене заявки 254 | self.oco_pc_check(order) # Проверяем связанные и родительскую/дочерние заявки (Canceled) 255 | return order # В список уведомлений ничего не добавляем. Ждем события on_order 256 | 257 | def oco_pc_check(self, order): 258 | """ 259 | Проверка связанных заявок 260 | Проверка родительской/дочерних заявок 261 | """ 262 | ocos = self.ocos.copy() # Пока ищем связанные заявки, они могут измениться. Поэтому, работаем с копией 263 | for order_ref, oco_ref in ocos.items(): # Пробегаемся по списку связанных заявок 264 | if oco_ref == order.ref: # Если в заявке номер эта заявка указана как связанная (по номеру транзакции) 265 | self.cancel_order(self.orders[order_ref]) # то отменяем заявку 266 | if order.ref in ocos.keys(): # Если у этой заявки указана связанная заявка 267 | oco_ref = ocos[order.ref] # то получаем номер транзакции связанной заявки 268 | self.cancel_order(self.orders[oco_ref]) # отменяем связанную заявку 269 | 270 | if not order.parent and not order.transmit and order.status == Order.Completed: # Если исполнена родительская заявка 271 | pcs = self.pcs[order.ref] # Получаем очередь родительской/дочерних заявок 272 | for child in pcs: # Пробегаемся по всем заявкам 273 | if child.parent: # Пропускаем первую (родительскую) заявку 274 | self.place_order(child) # Отправляем дочернюю заявку на биржу 275 | elif order.parent: # Если исполнена/отменена дочерняя заявка 276 | pcs = self.pcs[order.parent.ref] # Получаем очередь родительской/дочерних заявок 277 | for child in pcs: # Пробегаемся по всем заявкам 278 | if child.parent and child.ref != order.ref: # Пропускаем первую (родительскую) заявку и исполненную заявку 279 | self.cancel_order(child) # Отменяем дочернюю заявку 280 | 281 | def on_order_trades(self, event: OrderTrades): 282 | order: Order = self.get_order(event.order_id) # Заявка BackTrader 283 | for trade in event.trades: # Пробегаемся по всем сделкам заявки 284 | dt = self.store.provider.timestamp_to_msk_datetime(trade.date_time) # Дата и время сделки по времени биржи (МСК) 285 | pos = self.getposition(order.data) # Получаем позицию по тикеру или нулевую позицию если тикера в списке позиций нет 286 | size = trade.quantity # Количество штук в сделке 287 | price = trade.price # Цена за 1 инструмент, по которой совершена сделка 288 | psize, pprice, opened, closed = pos.update(size, price) # Обновляем размер/цену позиции на размер/цену сделки 289 | order.execute(dt, size, price, closed, 0, 0, opened, 0, 0, 0, 0, psize, pprice) # Исполняем заявку в BackTrader 290 | if order.executed.remsize: # Если осталось что-то к исполнению 291 | if order.status != order.Partial: # Если заявка переходит в статус частичного исполнения (может исполняться несколькими частями) 292 | order.partial() # то заявка частично исполнена 293 | self.notifs.append(order.clone()) # Уведомляем брокера о частичном исполнении заявки 294 | else: # Если зничего нет к исполнению 295 | order.completed() # то заявка полностью исполнена 296 | self.notifs.append(order.clone()) # Уведомляем брокера о полном исполнении заявки 297 | # Снимаем oco-заявку только после полного исполнения заявки 298 | # Если нужно снять oco-заявку на частичном исполнении, то прописываем это правило в ТС 299 | self.oco_pc_check(order) # Проверяем связанные и родительскую/дочерние заявки (Completed) 300 | -------------------------------------------------------------------------------- /TKData.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone, timedelta, time 2 | from threading import Thread, Event # Поток и событие остановки потока получения новых бар по расписанию биржи 3 | import os.path 4 | import csv 5 | import logging 6 | 7 | from backtrader.feed import AbstractDataBase 8 | from backtrader.utils.py3 import with_metaclass 9 | from backtrader import TimeFrame, date2num 10 | 11 | from BackTraderTinkoff import TKStore 12 | from TinkoffPy.grpc.marketdata_pb2 import SubscriptionInterval, CandleInterval, MarketDataRequest, SubscribeCandlesRequest, SubscriptionAction, CandleInstrument, GetCandlesRequest 13 | from google.protobuf.timestamp_pb2 import Timestamp 14 | from google.protobuf.json_format import MessageToDict 15 | 16 | 17 | class MetaTKData(AbstractDataBase.__class__): 18 | def __init__(self, name, bases, dct): 19 | super(MetaTKData, self).__init__(name, bases, dct) # Инициализируем класс данных 20 | TKStore.DataCls = self # Регистрируем класс данных в хранилище Alor 21 | 22 | 23 | class TKData(with_metaclass(MetaTKData, AbstractDataBase)): 24 | """Данные Тинькофф""" 25 | params = ( 26 | ('account_id', 0), # Порядковый номер счета 27 | ('four_price_doji', False), # False - не пропускать дожи 4-х цен, True - пропускать 28 | ('schedule', None), # Расписание работы биржи 29 | ('live_bars', False), # False - только история, True - история и новые бары 30 | ) 31 | datapath = os.path.join('..', '..', 'Data', 'Tinkoff', '') # Путь сохранения файла истории 32 | delimiter = '\t' # Разделитель значений в файле истории. По умолчанию табуляция 33 | dt_format = '%d.%m.%Y %H:%M' # Формат представления даты и времени в файле истории. По умолчанию русский формат 34 | 35 | def islive(self): 36 | """Если подаем новые бары, то Cerebro не будет запускать preload и runonce, т.к. новые бары должны идти один за другим""" 37 | return self.p.live_bars 38 | 39 | def __init__(self, **kwargs): 40 | self.store = TKStore(**kwargs) # Передаем параметры в хранилище Тинькофф. Может работать самостоятельно, не через хранилище 41 | self.intraday = self.p.timeframe == TimeFrame.Minutes # Внутридневной временной интервал 42 | self.class_code, self.symbol = self.store.provider.dataname_to_class_code_symbol(self.p.dataname) # По тикеру получаем код режима торгов и тикера 43 | self.account = self.store.provider.accounts[self.p.account_id] # Счет тикера 44 | self.tinkoff_timeframe = self.bt_timeframe_to_tinfoff_timeframe(self.p.timeframe, self.p.compression) # Конвертируем временной интервал из BackTrader в Тинькофф 45 | self.subscription_interval = SubscriptionInterval.SUBSCRIPTION_INTERVAL_ONE_MINUTE if self.tinkoff_timeframe == CandleInterval.CANDLE_INTERVAL_1_MIN else\ 46 | SubscriptionInterval.SUBSCRIPTION_INTERVAL_FIVE_MINUTES if self.tinkoff_timeframe == CandleInterval.CANDLE_INTERVAL_5_MIN else None # Интервал подписки 1, 5 минут или нет подписки 47 | self.tf = self.bt_timeframe_to_tf(self.p.timeframe, self.p.compression) # Конвертируем временной интервал из BackTrader для имени файла истории и расписания 48 | self.file = f'{self.class_code}.{self.symbol}_{self.tf}' # Имя файла истории 49 | self.logger = logging.getLogger(f'TKData.{self.file}') # Будем вести лог 50 | self.file_name = f'{self.datapath}{self.file}.txt' # Полное имя файла истории 51 | self.figi = self.store.provider.get_symbol_info(self.class_code, self.symbol).figi # По коду режима торгов и тикера получаем уникальный код тикера 52 | self.history_bars = [] # Исторические бары после применения фильтров 53 | self.exit_event = Event() # Определяем событие выхода из потока 54 | self.dt_last_open = datetime.min # Дата и время открытия последнего полученного бара 55 | self.last_bar_received = False # Получен последний бар 56 | self.live_mode = False # Режим получения бар. False = История, True = Новые бары 57 | 58 | def setenvironment(self, env): 59 | """Добавление хранилища Тинькофф в cerebro""" 60 | super(TKData, self).setenvironment(env) 61 | env.addstore(self.store) # Добавление хранилища Тинькофф в cerebro 62 | 63 | def start(self): 64 | super(TKData, self).start() 65 | self.put_notification(self.DELAYED) # Отправляем уведомление об отправке исторических (не новых) баров 66 | self.get_bars_from_file() # Получаем бары из файла 67 | self.get_bars_from_history() # Получаем бары из истории 68 | if len(self.history_bars) > 0: # Если был получен хотя бы 1 бар 69 | self.put_notification(self.CONNECTED) # то отправляем уведомление о подключении и начале получения исторических баров 70 | if self.p.live_bars: # Если получаем историю и новые бары 71 | if self.p.schedule: # Если получаем новые бары по расписанию 72 | Thread(target=self.stream_bars).start() # Создаем и запускаем получение новых бар по расписанию в потоке 73 | else: # Если получаем новые бары по подписке 74 | if self.tinkoff_timeframe not in (CandleInterval.CANDLE_INTERVAL_1_MIN, CandleInterval.CANDLE_INTERVAL_5_MIN): # Подписываться возможно на интервалы 1 и 5 минут 75 | raise NotImplementedError # Остальные временнЫе интервалы не реализованы в API 76 | self.logger.debug('Запуск подписки на новые бары') 77 | self.store.provider.subscription_marketdata_queue.put( # Ставим в буфер команд подписки на биржевую информацию 78 | MarketDataRequest(subscribe_candles_request=SubscribeCandlesRequest( # запрос на новые бары 79 | subscription_action=SubscriptionAction.SUBSCRIPTION_ACTION_SUBSCRIBE, # подписка 80 | instruments=(CandleInstrument(interval=self.subscription_interval, instrument_id=self.figi),), # на тикер по временному интервалу 81 | waiting_close=True))) # по закрытию бара 82 | 83 | def _load(self): 84 | """Загрузка бара из истории или нового бара""" 85 | if len(self.history_bars) > 0: # Если есть исторические данные 86 | bar = self.history_bars.pop(0) # Берем и удаляем первый бар из хранилища. С ним будем работать 87 | elif not self.p.live_bars: # Если получаем только историю (self.history_bars) и исторических данных нет / все исторические данные получены 88 | self.put_notification(self.DISCONNECTED) # Отправляем уведомление об окончании получения исторических бар 89 | self.logger.debug('Бары из файла/истории отправлены в ТС. Новые бары получать не нужно. Выход') 90 | return False # Больше сюда заходить не будем 91 | else: # Если получаем историю и новые бары (self.store.new_bars) 92 | if len(self.store.new_bars) == 0: # Если в хранилище никаких новых бар нет 93 | return None # то нового бара нет, будем заходить еще 94 | new_bars = [b for b in self.store.new_bars if b['guid'][0] == self.figi and b['guid'][1] == self.tf] # Смотрим в хранилище новых бар бары с guid подписки 95 | if len(new_bars) == 0: # Если новый бар еще не появился 96 | self.logger.debug('Новых бар нет') 97 | return None # то нового бара нет, будем заходить еще 98 | self.last_bar_received = len(new_bars) == 1 # Если в хранилище остался 1 бар, то мы будем получать последний возможный бар 99 | if self.last_bar_received: # Получаем последний возможный бар 100 | self.logger.debug('Получение последнего возможного на данный момент бара') 101 | new_bar = new_bars[0] # Берем первый бар из хранилища 102 | self.store.new_bars.remove(new_bar) # Убираем его из хранилища 103 | bar = new_bar['data'] # С данными этого бара будем работать 104 | if not self.is_bar_valid(bar): # Если бар не соответствует всем условиям выборки 105 | return None # то пропускаем бар, будем заходить еще 106 | self.logger.debug(f'Сохранение нового бара с {bar["datetime"].strftime(self.dt_format)} в файл') 107 | self.save_bar_to_file(bar) # Сохраняем бар в конец файла 108 | if self.last_bar_received and not self.live_mode: # Если получили последний бар и еще не находимся в режиме получения новых бар (LIVE) 109 | self.put_notification(self.LIVE) # Отправляем уведомление о получении новых бар 110 | self.live_mode = True # Переходим в режим получения новых бар (LIVE) 111 | elif self.live_mode and not self.last_bar_received: # Если находимся в режиме получения новых бар (LIVE) 112 | self.put_notification(self.DELAYED) # Отправляем уведомление об отправке исторических (не новых) бар 113 | self.live_mode = False # Переходим в режим получения истории 114 | # Все проверки пройдены. Записываем полученный исторический/новый бар 115 | self.lines.datetime[0] = date2num(bar['datetime']) # DateTime 116 | self.lines.open[0] = bar['open'] # Open 117 | self.lines.high[0] = bar['high'] # High 118 | self.lines.low[0] = bar['low'] # Low 119 | self.lines.close[0] = bar['close'] # Close 120 | self.lines.volume[0] = int(bar['volume']) # Volume подается как строка. Его обязательно нужно привести к целому 121 | self.lines.openinterest[0] = 0 # Открытый интерес в Финам не учитывается 122 | return True # Будем заходить сюда еще 123 | 124 | def stop(self): 125 | super(TKData, self).stop() 126 | if self.p.live_bars: # Если была подписка/расписание 127 | if self.p.schedule: # Если получаем новые бары по расписанию 128 | self.exit_event.set() # то отменяем расписание 129 | else: # Если получаем новые бары по подписке 130 | self.logger.info('Отмена подписки на новые бары') 131 | self.store.provider.subscription_marketdata_queue.put( # Ставим в буфер команд подписки на биржевую информацию 132 | MarketDataRequest(subscribe_candles_request=SubscribeCandlesRequest( # запрос на новые бары 133 | subscription_action=SubscriptionAction.SUBSCRIPTION_ACTION_UNSUBSCRIBE, # отмена подписки 134 | instruments=(CandleInstrument(interval=self.subscription_interval, instrument_id=self.figi),), # на тикер по временному интервалу 135 | waiting_close=True))) # по закрытию бара 136 | self.put_notification(self.DISCONNECTED) # Отправляем уведомление об окончании получения новых бар 137 | self.store.DataCls = None # Удаляем класс данных в хранилище 138 | 139 | # Получение бар 140 | 141 | def get_bars_from_file(self) -> None: 142 | """Получение бар из файла""" 143 | if not os.path.isfile(self.file_name): # Если файл не существует 144 | return # то выходим, дальше не продолжаем 145 | self.logger.debug(f'Получение бар из файла {self.file_name}') 146 | with open(self.file_name) as file: # Открываем файл на последовательное чтение 147 | reader = csv.reader(file, delimiter=self.delimiter) # Данные в строке разделены табуляцией 148 | next(reader, None) # Пропускаем первую строку с заголовками 149 | for csv_row in reader: # Последовательно получаем все строки файла 150 | bar = dict(datetime=datetime.strptime(csv_row[0], self.dt_format), 151 | open=float(csv_row[1]), high=float(csv_row[2]), low=float(csv_row[3]), close=float(csv_row[4]), 152 | volume=int(csv_row[5])) # Бар из файла 153 | if self.is_bar_valid(bar): # Если исторический бар соответствует всем условиям выборки 154 | self.history_bars.append(bar) # то добавляем бар 155 | if len(self.history_bars) > 0: # Если были получены бары из файла 156 | self.logger.debug(f'Получено бар из файла: {len(self.history_bars)} с {self.history_bars[0]["datetime"].strftime(self.dt_format)} по {self.history_bars[-1]["datetime"].strftime(self.dt_format)}') 157 | else: # Бары из файла не получены 158 | self.logger.debug('Из файла новых бар не получено') 159 | 160 | def get_bars_from_history(self) -> None: 161 | """Получение бар из истории""" 162 | file_history_bars_len = len(self.history_bars) # Кол-во полученных бар из файла для лога 163 | if self.dt_last_open > datetime.min: # Если в файле были бары 164 | last_date = self.dt_last_open # Дата и время последнего бара из файла по МСК 165 | next_bar_open_utc = self.store.provider.msk_to_utc_datetime(last_date + timedelta(minutes=1), True) if self.intraday else \ 166 | last_date.replace(tzinfo=timezone.utc) + timedelta(days=1) # Смещаем время на возможный следующий бар по UTC 167 | else: # Если в файле не было баров 168 | si = self.store.provider.get_symbol_info(self.class_code, self.symbol) # Информация о тикере 169 | next_bar_open_utc = datetime.fromtimestamp(si.first_1min_candle_date.seconds, timezone.utc) if self.intraday else \ 170 | datetime.fromtimestamp(si.first_1day_candle_date.seconds, timezone.utc) # Первый минутный/дневной бар истории 171 | todate_utc = datetime.utcnow().replace(tzinfo=timezone.utc) # Будем получать бары до текущей даты и времени UTC 172 | _, td = self.store.provider.tinkoff_timeframe_to_timeframe(self.tinkoff_timeframe) # Максимальный период запроса 173 | while True: # Будем получать бары пока не получим все 174 | request = GetCandlesRequest(instrument_id=self.figi, interval=self.tinkoff_timeframe) # Запрос на получение бар 175 | from_ = getattr(request, 'from') # т.к. from - ключевое слово в Python, то получаем атрибут from из атрибута интервала 176 | to_ = getattr(request, 'to') # Аналогично будем работать с атрибутом to для единообразия 177 | from_.seconds = Timestamp(seconds=int(next_bar_open_utc.timestamp())).seconds # Дата и время начала интервала UTC 178 | todate_min_utc = min(todate_utc, next_bar_open_utc + td) # До какой даты можем делать запрос 179 | to_.seconds = Timestamp(seconds=int(todate_min_utc.timestamp())).seconds # Дата и время окончания интервала UTC 180 | self.logger.debug(f'Получение бар из истории с {next_bar_open_utc} по {todate_min_utc}') 181 | response = self.store.provider.call_function(self.store.provider.stub_marketdata.GetCandles, request) # Получаем ответ на запрос бар 182 | if not response: # Если в ответ ничего не получили 183 | self.logger.warning('Ошибка запроса бар из истории') 184 | return # то выходим, дальше не продолжаем 185 | response_dict = MessageToDict(response, including_default_value_fields=True) # Переводим в словарь из JSON 186 | if 'candles' not in response_dict: # Если бар нет в словаре 187 | self.logger.error(f'Бар (candles) нет в словаре {response_dict}') 188 | return # то выходим, дальше не продолжаем 189 | new_bars_dict = response_dict['candles'] # Получаем все бары из Tinfoff 190 | if len(new_bars_dict) > 0: # Если пришли новые бары 191 | first_bar_open_dt = self.get_bar_open_date_time(new_bars_dict[0]) # Дату и время первого полученного бара переводим из UTC в МСК 192 | last_bar_open_dt = self.get_bar_open_date_time(new_bars_dict[-1]) # Дату и время последнего полученного бара переводим из UTC в МСК 193 | self.logger.debug(f'Получены бары с {first_bar_open_dt} по {last_bar_open_dt}') 194 | for new_bar in new_bars_dict: # Пробегаемся по всем полученным барам 195 | if not new_bar['isComplete']: # Если добрались до незавершенного бара 196 | break # то это последний бар, больше бары обрабатывать не будем 197 | bar = dict(datetime=self.get_bar_open_date_time(new_bar), 198 | open=self.store.provider.money_dict_value_to_float(new_bar['open']), 199 | high=self.store.provider.money_dict_value_to_float(new_bar['high']), 200 | low=self.store.provider.money_dict_value_to_float(new_bar['low']), 201 | close=self.store.provider.money_dict_value_to_float(new_bar['close']), 202 | volume=int(new_bar['volume'])) 203 | self.save_bar_to_file(bar) # Сохраняем бар в файл 204 | if self.is_bar_valid(bar): # Если исторический бар соответствует всем условиям выборки 205 | self.history_bars.append(bar) # то добавляем бар 206 | next_bar_open_utc = todate_min_utc + timedelta(minutes=1) if self.intraday else todate_min_utc + timedelta(days=1) # Смещаем время на возможный следующий бар UTC 207 | if next_bar_open_utc > todate_utc: # Если пройден весь интервал 208 | break # то выходим из цикла получения бар 209 | if len(self.history_bars) - file_history_bars_len > 0: # Если получены бары из истории 210 | self.logger.debug(f'Получено бар из истории: {len(self.history_bars) - file_history_bars_len} с {self.history_bars[file_history_bars_len]["datetime"].strftime(self.dt_format)} по {self.history_bars[-1]["datetime"].strftime(self.dt_format)}') 211 | else: # Бары из истории не получены 212 | self.logger.debug('Из истории новых бар не получено') 213 | 214 | def stream_bars(self) -> None: 215 | """Поток получения новых бар по расписанию биржи""" 216 | self.logger.debug('Запуск получения новых бар по расписанию') 217 | while True: 218 | market_datetime_now = self.p.schedule.utc_to_msk_datetime(datetime.utcnow()) # Текущее время на бирже 219 | trade_bar_open_datetime = self.p.schedule.trade_bar_open_datetime(market_datetime_now, self.tf) # Дата и время открытия бара, который будем получать 220 | trade_bar_request_datetime = self.p.schedule.trade_bar_request_datetime(market_datetime_now, self.tf) # Дата и время запроса бара на бирже 221 | sleep_time_secs = (trade_bar_request_datetime - market_datetime_now).total_seconds() # Время ожидания в секундах 222 | self.logger.debug(f'Получение новых бар с {trade_bar_open_datetime.strftime(self.dt_format)} по расписанию в {trade_bar_request_datetime.strftime(self.dt_format)}. Ожидание {sleep_time_secs} с') 223 | exit_event_set = self.exit_event.wait(sleep_time_secs) # Ждем нового бара или события выхода из потока 224 | if exit_event_set: # Если произошло событие выхода из потока 225 | self.logger.warning('Отмена получения новых бар по расписанию') 226 | return # Выходим из потока, дальше не продолжаем 227 | ts_from = Timestamp(seconds=self.p.schedule.msk_datetime_to_utc_timestamp(trade_bar_open_datetime)) # Дата и время открытия бара в Google Timestamp UTC 228 | trade_bar_close_datetime = self.p.schedule.trade_bar_close_datetime(market_datetime_now, self.tf) # Дата и время закрытия бара, который будем получать 229 | ts_to = Timestamp(seconds=self.p.schedule.msk_datetime_to_utc_timestamp(trade_bar_close_datetime)) # Дата и время закрытия бара в Google Timestamp UTC 230 | request = GetCandlesRequest(instrument_id=self.figi, to=ts_to, interval=self.tinkoff_timeframe) # Запрос на получение бар 231 | from_ = getattr(request, 'from') # т.к. from - ключевое слово в Python, то получаем атрибут from из атрибута интервала 232 | from_.seconds = ts_from.seconds # Устанавливаем значение через кол-во секунд 233 | response = self.store.provider.call_function(self.store.provider.stub_marketdata.GetCandles, request) # Получаем ответ на запрос бар 234 | if not response: # Если в ответ ничего не получили 235 | self.logger.warning('Ошибка запроса бар из истории по расписанию') 236 | continue # то будем получать следующий бар 237 | response_dict = MessageToDict(response, including_default_value_fields=True) # Получаем бары, переводим в словарь/список 238 | if 'candles' not in response_dict: # Если бар нет в словаре 239 | self.logger.warning(f'Бар (candles) нет в истории по расписанию {response_dict}') 240 | continue # то будем получать следующий бар 241 | bars = response_dict['candles'] # Последний сформированный и текущий несформированный (если имеется) бары 242 | if len(bars) == 0: # Если новых бар нет 243 | self.logger.warning('Новые бары по расписанию не получены') 244 | continue # Будем получать следующий бар 245 | new_bar = bars[0] # Получаем первый (завершенный) бар 246 | bar = dict(datetime=self.get_bar_open_date_time(new_bar), 247 | open=self.store.provider.money_dict_value_to_float(new_bar['open']), 248 | high=self.store.provider.money_dict_value_to_float(new_bar['high']), 249 | low=self.store.provider.money_dict_value_to_float(new_bar['low']), 250 | close=self.store.provider.money_dict_value_to_float(new_bar['close']), 251 | volume=int(new_bar['volume'])) 252 | self.logger.debug('Получен бар по расписанию') 253 | self.store.new_bars.append(dict(guid=(self.figi, self.tf), data=bar)) # Добавляем в хранилище новых бар 254 | 255 | # Функции 256 | 257 | @staticmethod 258 | def bt_timeframe_to_tinfoff_timeframe(timeframe, compression=1): 259 | """Перевод временнОго интервала из BackTrader в Тинькофф 260 | 261 | :param TimeFrame timeframe: Временной интервал 262 | :param int compression: Размер временнОго интервала 263 | :return: Временной интервал Тинькофф 264 | """ 265 | if timeframe == TimeFrame.Days: # Дневной временной интервал (по умолчанию) 266 | return CandleInterval.CANDLE_INTERVAL_DAY 267 | elif timeframe == TimeFrame.Weeks: # Недельный временной интервал 268 | return CandleInterval.CANDLE_INTERVAL_WEEK 269 | elif timeframe == TimeFrame.Months: # Месячный временной интервал 270 | return CandleInterval.CANDLE_INTERVAL_MONTH 271 | elif timeframe == TimeFrame.Minutes: # Минутный временной интервал 272 | if compression == 1: # 1 минута 273 | return CandleInterval.CANDLE_INTERVAL_1_MIN 274 | elif compression == 2: # 2 минуты 275 | return CandleInterval.CANDLE_INTERVAL_2_MIN 276 | elif compression == 3: # 3 минуты 277 | return CandleInterval.CANDLE_INTERVAL_3_MIN 278 | elif compression == 5: # 5 минут 279 | return CandleInterval.CANDLE_INTERVAL_5_MIN 280 | elif compression == 10: # 10 минут 281 | return CandleInterval.CANDLE_INTERVAL_10_MIN 282 | elif compression == 15: # 15 минут 283 | return CandleInterval.CANDLE_INTERVAL_15_MIN 284 | elif compression == 30: # 30 минут 285 | return CandleInterval.CANDLE_INTERVAL_30_MIN 286 | elif compression == 60: # 1 час 287 | return CandleInterval.CANDLE_INTERVAL_HOUR 288 | elif compression == 120: # 2 часа 289 | return CandleInterval.CANDLE_INTERVAL_2_HOUR 290 | elif compression == 240: # 4 часа 291 | return CandleInterval.CANDLE_INTERVAL_4_HOUR 292 | 293 | @staticmethod 294 | def bt_timeframe_to_tf(timeframe, compression=1) -> str: 295 | """Перевод временнОго интервала из BackTrader для имени файла истории и расписания https://ru.wikipedia.org/wiki/Таймфрейм 296 | 297 | :param TimeFrame timeframe: Временной интервал 298 | :param int compression: Размер временнОго интервала 299 | :return: Временной интервал для имени файла истории и расписания 300 | """ 301 | if timeframe == TimeFrame.Minutes: # Минутный временной интервал 302 | return f'M{compression}' 303 | # Часовой график f'H{compression}' заменяем минутным. Пример: H1 = M60 304 | elif timeframe == TimeFrame.Days: # Дневной временной интервал 305 | return f'D1' 306 | elif timeframe == TimeFrame.Weeks: # Недельный временной интервал 307 | return f'W1' 308 | elif timeframe == TimeFrame.Months: # Месячный временной интервал 309 | return f'MN1' 310 | elif timeframe == TimeFrame.Years: # Годовой временной интервал 311 | return f'Y1' 312 | raise NotImplementedError # С остальными временнЫми интервалами не работаем 313 | 314 | def get_bar_open_date_time(self, bar): 315 | """Дата и время открытия бара. Переводим из UTC в MSK для интрадея. Оставляем без времени для дневок и выше.""" 316 | dt_utc = datetime.fromisoformat(bar['time'][:-1]) # Дата и время начала бара в UTC 317 | return self.store.provider.utc_to_msk_datetime(dt_utc) if self.intraday else \ 318 | datetime(dt_utc.year, dt_utc.month, dt_utc.day) # Дату/время переводим из UTC в МСК 319 | 320 | def get_bar_close_date_time(self, dt_open, period=1): 321 | """Дата и время закрытия бара""" 322 | if self.p.timeframe == TimeFrame.Days: # Дневной временной интервал (по умолчанию) 323 | return dt_open + timedelta(days=period) # Время закрытия бара 324 | elif self.p.timeframe == TimeFrame.Weeks: # Недельный временной интервал 325 | return dt_open + timedelta(weeks=period) # Время закрытия бара 326 | elif self.p.timeframe == TimeFrame.Months: # Месячный временной интервал 327 | year = dt_open.year # Год 328 | next_month = dt_open.month + period # Добавляем месяцы 329 | if next_month > 12: # Если произошло переполнение месяцев 330 | next_month -= 12 # то вычитаем год из месяцев 331 | year += 1 # ставим следующий год 332 | return datetime(year, next_month, 1) # Время закрытия бара 333 | elif self.p.timeframe == TimeFrame.Years: # Годовой временной интервал 334 | return dt_open.replace(year=dt_open.year + period) # Время закрытия бара 335 | elif self.p.timeframe == TimeFrame.Minutes: # Минутный временной интервал 336 | return dt_open + timedelta(minutes=self.p.compression * period) # Время закрытия бара 337 | elif self.p.timeframe == TimeFrame.Seconds: # Секундный временной интервал 338 | return dt_open + timedelta(seconds=self.p.compression * period) # Время закрытия бара 339 | 340 | def is_bar_valid(self, bar) -> bool: 341 | """Проверка бара на соответствие условиям выборки""" 342 | dt_open = bar['datetime'] # Дата и время открытия бара МСК 343 | if dt_open <= self.dt_last_open: # Если пришел бар из прошлого (дата открытия меньше последней даты открытия) 344 | self.logger.debug(f'Дата/время открытия бара {dt_open} <= последней даты/времени открытия {self.dt_last_open}') 345 | return False # то бар не соответствует условиям выборки 346 | else: # Бар не из прошлого 347 | self.dt_last_open = dt_open # Запоминаем дату/время открытия пришедшего бара для будущих сравнений 348 | if self.p.fromdate and dt_open < self.p.fromdate or self.p.todate and dt_open > self.p.todate: # Если задан диапазон, а бар за его границами 349 | # self.logger.debug(f'Дата/время открытия бара {dt_open} за границами диапазона {self.p.fromdate} - {self.p.todate}') 350 | return False # то бар не соответствует условиям выборки 351 | if self.p.sessionstart != time.min and dt_open.time() < self.p.sessionstart: # Если задано время начала сессии и открытие бара до этого времени 352 | self.logger.debug(f'Дата/время открытия бара {dt_open} до начала торговой сессии {self.p.sessionstart}') 353 | return False # то бар не соответствует условиям выборки 354 | dt_close = self.get_bar_close_date_time(dt_open) # Дата и время закрытия бара 355 | if self.p.sessionend != time(23, 59, 59, 999990) and dt_close.time() > self.p.sessionend: # Если задано время окончания сессии и закрытие бара после этого времени 356 | self.logger.debug(f'Дата/время открытия бара {dt_open} после окончания торговой сессии {self.p.sessionend}') 357 | return False # то бар не соответствует условиям выборки 358 | if not self.p.four_price_doji and bar['high'] == bar['low']: # Если не пропускаем дожи 4-х цен, но такой бар пришел 359 | # self.logger.debug(f'Бар {dt_open} - дожи 4-х цен') 360 | return False # то бар не соответствует условиям выборки 361 | time_market_now = self.get_tinkoff_date_time_now() # Текущее биржевое время 362 | if dt_close > time_market_now and time_market_now.time() < self.p.sessionend: # Если время закрытия бара еще не наступило на бирже, и сессия еще не закончилась 363 | self.logger.debug(f'Дата/время {dt_close} закрытия бара еще не наступило') 364 | return False # то бар не соответствует условиям выборки 365 | return True # В остальных случаях бар соответствуем условиям выборки 366 | 367 | def save_bar_to_file(self, bar) -> None: 368 | """Сохранение бара в конец файла""" 369 | if not os.path.isfile(self.file_name): # Существует ли файл 370 | self.logger.warning(f'Файл {self.file_name} не найден и будет создан') 371 | with open(self.file_name, 'w', newline='') as file: # Создаем файл 372 | writer = csv.writer(file, delimiter=self.delimiter) # Данные в строке разделены табуляцией 373 | writer.writerow(bar.keys()) # Записываем заголовок в файл 374 | with open(self.file_name, 'a', newline='') as file: # Открываем файл на добавление в конец. Ставим newline, чтобы в Windows не создавались пустые строки в файле 375 | writer = csv.writer(file, delimiter=self.delimiter) # Данные в строке разделены табуляцией 376 | csv_row = bar.copy() # Копируем бар для того, чтобы изменить формат даты 377 | csv_row['datetime'] = csv_row['datetime'].strftime(self.dt_format) # Приводим дату к формату файла 378 | writer.writerow(csv_row.values()) # Записываем бар в конец файла 379 | self.logger.debug(f'В файл {self.file_name} записан бар на {csv_row["datetime"]}') 380 | 381 | def get_tinkoff_date_time_now(self): 382 | """Текущая дата и время на сервере Тинькофф с учетом разницы (передается в подписках раз в 4 минуты)""" 383 | return datetime.now(self.store.provider.tz_msk).replace(tzinfo=None) + self.store.provider.time_delta 384 | --------------------------------------------------------------------------------