├── robotlib ├── __init__.py ├── vizualization.py ├── money.py ├── stats.py ├── strategy.py └── robot.py ├── mkdocs.yml ├── .github └── workflows │ └── ci.yml ├── main_stats.py ├── requirements.txt ├── main.py ├── README.md ├── docs ├── visualizer.md ├── money.md ├── index.md ├── robot.md ├── strategy.md └── stats.md ├── .gitignore └── LICENSE /robotlib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Tinkoff Invest Trade Robot by karpp 2 | docs_dir: docs 3 | site_url: https://karpp.github.io/investRobot/ 4 | repo_url: https://github.com/karpp/investRobot 5 | theme: 6 | name: material 7 | language: 'ru' 8 | palette: 9 | primary: black 10 | accent: red 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: deploy-docs 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - main 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-python@v2 13 | with: 14 | python-version: 3.x 15 | - run: pip install mkdocs-material 16 | - run: mkdocs gh-deploy --force 17 | -------------------------------------------------------------------------------- /main_stats.py: -------------------------------------------------------------------------------- 1 | from robotlib.stats import BalanceProcessor, BalanceCalculator, TradeStatisticsAnalyzer 2 | 3 | 4 | def main(): 5 | stats = TradeStatisticsAnalyzer.load_from_file('/Users/egor/Dev/tinvest/robot/stats.pickle') 6 | 7 | short, full = stats.get_report(processors=[BalanceProcessor()], calculators=[BalanceCalculator()]) 8 | 9 | print(full) 10 | print(short) 11 | 12 | 13 | if __name__ == '__main__': 14 | main() 15 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click==8.1.3 2 | cycler==0.11.0 3 | et-xmlfile==1.1.0 4 | fonttools==4.33.3 5 | ghp-import==2.1.0 6 | grpcio==1.46.1 7 | importlib-metadata==4.11.4 8 | Jinja2==3.1.2 9 | kiwisolver==1.4.2 10 | Markdown==3.3.7 11 | MarkupSafe==2.1.1 12 | matplotlib==3.5.2 13 | mergedeep==1.3.4 14 | numpy==1.22.3 15 | openpyxl==3.0.9 16 | packaging==21.3 17 | pandas==1.4.2 18 | Pillow==9.1.0 19 | plotly==5.8.0 20 | protobuf==3.20.1 21 | Pygments==2.12.0 22 | pymdown-extensions==9.4 23 | pyparsing==3.0.9 24 | python-dateutil==2.8.2 25 | pytz==2022.1 26 | PyYAML==6.0 27 | pyyaml_env_tag==0.1 28 | six==1.16.0 29 | TA-Lib==0.4.24 30 | tenacity==8.0.1 31 | tinkoff==0.1.1 32 | tinkoff-investments==0.2.0b27 33 | watchdog==2.1.8 34 | zipp==3.8.0 35 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | 4 | from robotlib.robot import TradingRobotFactory 5 | from robotlib.strategy import TradeStrategyParams, MAEStrategy 6 | from robotlib.vizualization import Visualizer 7 | 8 | token = os.environ.get('TINKOFF_TOKEN') 9 | account_id = os.environ.get('TINKOFF_ACCOUNT') 10 | 11 | 12 | def backtest(robot): 13 | stats = robot.backtest( 14 | TradeStrategyParams(instrument_balance=0, currency_balance=15000, pending_orders=[]), 15 | train_duration=datetime.timedelta(days=5), test_duration=datetime.timedelta(days=30)) 16 | stats.save_to_file('backtest_stats.pickle') 17 | 18 | 19 | def trade(robot): 20 | stats = robot.trade() 21 | stats.save_to_file('stats.pickle') 22 | 23 | 24 | def main(): 25 | robot_factory = TradingRobotFactory(token=token, account_id=account_id, ticker='YNDX', class_code='TQBR', 26 | logger_level='INFO') 27 | robot = robot_factory.create_robot(MAEStrategy(visualizer=Visualizer('YNDX', 'RUB')), sandbox_mode=True) 28 | 29 | backtest(robot) 30 | 31 | trade(robot) 32 | 33 | 34 | if __name__ == '__main__': 35 | main() 36 | -------------------------------------------------------------------------------- /robotlib/vizualization.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | 3 | 4 | class Visualizer: 5 | def __init__(self, ticker, currency): 6 | self.ticker = ticker 7 | self.currency = currency 8 | self.fig = plt.figure() 9 | self.buys = [] 10 | self.sells = [] 11 | self.prices = {} 12 | 13 | def add_price(self, time, price: float): 14 | self.prices[time] = price 15 | 16 | def add_buy(self, time): 17 | self.buys.append(time) 18 | 19 | def add_sell(self, time): 20 | self.sells.append(time) 21 | 22 | def update_plot(self): 23 | self.fig.clear() 24 | 25 | x = list(self.prices.keys())[-50:] 26 | y = list(self.prices.values())[-50:] 27 | 28 | minx = min(x) 29 | buys = [buy for buy in self.buys if buy >= minx] 30 | sells = [sell for sell in self.sells if sell >= minx] 31 | 32 | plt.title(self.ticker) 33 | plt.xlabel('time') 34 | plt.ylabel(f'price ({self.currency})') 35 | plt.plot(x, y) 36 | plt.vlines(buys, ymin=min(y), ymax=max(y), color='g') 37 | plt.vlines(sells, ymin=min(y), ymax=max(y), color='r') 38 | plt.draw() 39 | plt.pause(0.2) 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # investRobot 2 | 3 | investRobot - это робот для алгоритмической торговли на бирже [Тинькофф Инвестиций](https://www.tinkoff.ru/invest/) 4 | посредством [Tinkoff Invest API](https://github.com/Tinkoff/investAPI). 5 | 6 | [Документация для разработчиков](https://karpp.github.io/investRobot/) 7 | 8 | ## Функциональные возможности 9 | 10 | * Автоматическая торговля любыми ценными бумагами 11 | * Реализация собственного торгового алгоритма 12 | * Выставление и отмена как рыночных, так и лимитных торговых поручений 13 | * Ведение статистики, выдача краткого результата 14 | * Логирование всех операций, возможность установки уровня логирования 15 | * Подключение визуализации 16 | 17 | 18 | ## Запуск робота 19 | 20 | 1. Установите зависимости `python3.10 -m pip install -r requirements.txt`; 21 | 2. Получите токен и сохраните его и ID аккаунта в переменные окружения TINKOFF_TOKEN и TINKOFF_ACCOUNT соответственно; 22 | 3. Запустите файл [main.py](main.py) `python3.10 main.py`. 23 | 24 | ## Торговая стратегия 25 | 26 | В качестве демонстрации представлена одна торговая стратегия, основанная на индиакторе двух скользящих средних. 27 | Строятся линии двух скользящих средних, короткого и длинного. Интервалы задаются в параметрах стратегии. 28 | При пересечении линий считается, что тренд цены меняется, и нужно либо покупать (в случае, если короткое среднее выше 29 | длинного), либо продавать (в обратном случае). 30 | -------------------------------------------------------------------------------- /docs/visualizer.md: -------------------------------------------------------------------------------- 1 | # Модуль visualizer 2 | 3 | ## `Visualizer` 4 | 5 | Позволяет подключить наглядную визуализацию курса ценной бумаги, а так же 6 | покупок и продаж робота в произвольную стратегию. Для этого необходимо созать 7 | объект класса `Visualizer`, после чего добавлять в него все обновления цены 8 | методом `add_price(time, price)`, покупки и продажи методами `add_buy(time)`, 9 | `add_sell(time)` соответственно. 10 | 11 | ### Методы 12 | 13 | #### __init__ 14 | 15 | *Входные данные*: 16 | 17 | | Field | Type | Description | 18 | |----------|------|-------------| 19 | | ticker | str | Тикер | 20 | | currency | str | Валюта | 21 | 22 | *Выходные данные*: `Visualizer`. 23 | 24 | #### add_price 25 | 26 | Сохраняет в визуализатор обновление цены. 27 | 28 | *Входные данные*: 29 | 30 | | Field | Type | Description | 31 | |-------|-------------------|------------------| 32 | | time | datetime.datetime | Время обновления | 33 | | price | float | Цена | 34 | 35 | #### add_buy 36 | 37 | Сохраняет в визуализатор покупку. 38 | 39 | *Входные данные*: 40 | 41 | | Field | Type | Description | 42 | |-------|-------------------|------------------| 43 | | time | datetime.datetime | Время обновления | 44 | 45 | #### add_sell 46 | 47 | Сохраняет в визуализатор продажу. 48 | 49 | *Входные данные*: 50 | 51 | | Field | Type | Description | 52 | |-------|-------------------|------------------| 53 | | time | datetime.datetime | Время обновления | 54 | 55 | #### update_plot 56 | 57 | Обновляет график. -------------------------------------------------------------------------------- /docs/money.md: -------------------------------------------------------------------------------- 1 | # Модуль money 2 | 3 | Содержит класс `Money`, используемый для точного хранения роботом денежных типов данных. Устроен аналогично 4 | классам `Quotation` и `MoneyValue` в [Tinkoff Invest API](https://tinkoff.github.io/investAPI/faq_custom_types/#quotation), 5 | вдобавок в нем реализованы методы преобразования в/из int, float, Quotation, MoneyValue, а также операторы сложения, 6 | вычитания, умножения на число 7 | 8 | ## Money 9 | 10 | ### Методы 11 | 12 | #### __init__ 13 | *Входные данные*: 14 | 15 | | Field | Type | Description | 16 | |-------|--------------------------------------|-------------------------------------------------------------| 17 | | value | int / float / Quotation / MoneyValue | Значение | 18 | | nano | Optional[int] | Значение nano (при использовании необходимо value типа int) | 19 | 20 | *Выходные данные*: Money. 21 | 22 | #### to_float 23 | Преобразовывает значение в float. 24 | 25 | #### to_quotation 26 | Преобразовывает значение в Quotation. 27 | 28 | #### to_money_value 29 | Преобразовывает значение в MoneyValue. 30 | 31 | *Входные данные*: 32 | 33 | | Field | Type | Description | 34 | |----------|------|-------------------------------------------------| 35 | | currency | str | Валюта, в которой необходимо вернуть MoneyValue | 36 | 37 | *Выходные данные*: MoneyValue. 38 | 39 | 40 | ### Примеры использования 41 | 42 | ```python 43 | from tinkoff.invest import Quotation 44 | from robotlib.money import Money 45 | q = Quotation(units=1600, nano=250000000) 46 | money_1 = Money(57.25) 47 | money_2 = Money(1000) 48 | 49 | money = Money(q) + money_1 + money_2 50 | print(money) 51 | # output: 52 | ``` -------------------------------------------------------------------------------- /robotlib/money.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import math 4 | 5 | from dataclasses import dataclass 6 | from tinkoff.invest import MoneyValue, Quotation 7 | 8 | 9 | @dataclass(init=False, order=True) 10 | class Money: 11 | units: int 12 | nano: int 13 | MOD: int = 10 ** 9 14 | 15 | def __init__(self, value: int | float | Quotation | MoneyValue, nano: int = None): 16 | if nano: 17 | assert isinstance(value, int), 'if nano is present, value must be int' 18 | assert isinstance(nano, int), 'nano must be int' 19 | self.units = value 20 | self.nano = nano 21 | else: 22 | match value: 23 | case int() as value: 24 | self.units = value 25 | self.nano = 0 26 | case float() as value: 27 | self.units = int(math.floor(value)) 28 | self.nano = int((value - math.floor(value)) * self.MOD) 29 | case Quotation() | MoneyValue() as value: 30 | self.units = value.units 31 | self.nano = value.nano 32 | case _: 33 | raise ValueError(f'{type(value)} is not supported as initial value for Money') 34 | 35 | def __float__(self): 36 | return self.units + self.nano / self.MOD 37 | 38 | def to_float(self): 39 | return float(self) 40 | 41 | def to_quotation(self): 42 | return Quotation(self.units, self.nano) 43 | 44 | def to_money_value(self, currency: str): 45 | return MoneyValue(currency, self.units, self.nano) 46 | 47 | def __add__(self, other: Money) -> Money: 48 | print(self.units + other.units + (self.nano + other.nano) // self.MOD) 49 | print((self.nano + other.nano) % self.MOD) 50 | return Money( 51 | self.units + other.units + (self.nano + other.nano) // self.MOD, 52 | (self.nano + other.nano) % self.MOD 53 | ) 54 | 55 | def __neg__(self) -> Money: 56 | return Money(-self.units, -self.nano) 57 | 58 | def __sub__(self, other: Money) -> Money: 59 | return self + -other 60 | 61 | def __mul__(self, other: int) -> Money: 62 | return Money(self.units * other + (self.nano * other) // self.MOD, (self.nano * other) % self.MOD) 63 | 64 | def __str__(self) -> str: 65 | return f'' 66 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Начало работы 2 | 3 | Tinkoff Invest Trade Robot - это робот для алгоритмической торговли на бирже Тинькофф Инвестиций посредством Tinkoff Invest API. 4 | 5 | ## Функциональные возможности 6 | 7 | * Автоматическая торговля любыми ценными бумагами 8 | * Реализация собственного торгового алгоритма 9 | * Выставление и отмена как рыночных, так и лимитных торговых поручений 10 | * Ведение статистики, выдача краткого результата 11 | * Логирование всех операций, возможность установки уровня логирования 12 | * Подключение визуализации 13 | 14 | ## Запуск робота 15 | 16 | 1. Установите зависимости `python3.10 -m pip install -r requrements.txt`; 17 | 2. Получите токен и сохраните его и ID аккаунта в переменные окружения TINKOFF_TOKEN и TINKOFF_ACCOUNT соответственно; 18 | 3. Запустите файл [main.py](main.py) `python3.10 main.py`. 19 | 20 | ## Торговая стратегия 21 | 22 | В качестве демонстрации представлена одна торговая стратегия, основанная на индиакторе двух скользящих средних. 23 | Строятся линии двух скользящих средних, короткого и длинного. Интервалы задаются в параметрах стратегии. 24 | При пересечении линий считается, что тренд цены меняется, и нужно либо покупать (в случае, если короткое среднее выше 25 | длинного), либо продавать (в обратном случае). 26 | 27 | 28 | ## Архитектура проекта 29 | 30 | Здесь находится короткое описание архитектуры. Более подробно можно прочитать на странице каждого из модулей. 31 | 32 | ### `robotlib/robot.py` 33 | #### `TradeRobotFactory` - фабрика роботов 34 | Получает из API параметры ценной бумаги и аккаунта, и используя их создает робота. 35 | 36 | #### `TradeRobot` - торговый робот 37 | Выступает в роли прокси между API Тинькофф Инвестиций и торговой стратегией. 38 | 39 | При запуске метода `trade()` загружает исторические данные, сохраняет их в торговую стратегию, 40 | подписывается на bidirectional-stream необходимой для стратегии биржевой информации. При получении обновлений, 41 | передает их в стратегию, дожидается от нее решения по дальнейшим действиям и исполняет их. 42 | 43 | Вся статистика торговых поручений робота записывается в объект типа `TradeStatisticsAnalyzer` и возвращается в конце работы 44 | для дальнейшего анализа или сохранения. Также может быть получена в любой момент из `robot.trade_statistics`. 45 | 46 | В зависимости от режима работы, выставленного в параметрах робота, он может торговать как в песочнице, так и на бирже. 47 | 48 | Также робот имеет функцию бэктеста стратегии `backtest()`. 49 | 50 | ### `robotlib/stats.py` 51 | #### `TradeStatisticsAnalyzer` - статистика робота 52 | Используется для записи, хранения и получения статистики, а также генерации отчетов и коротких сводок. Ведение статистики 53 | полностью ведется роботом, пользователю рекомендуется использовать только функции сохранения в файл и генерации 54 | отчетов. 55 | 56 | Для сохранения и чтения из файла есть методы `save_to_file(filename)` и `read_from_file(filename)` соответственно. 57 | 58 | Для генерации отчета используется метод `get_report(processors, calculators)`. Пользователям предоставляется возможность 59 | кастомизации отчетов посредством передачи в метод списка процессоров и калькуляторов. Подробнее дальше. 60 | 61 | #### `TradeStatisticsProcessorBase` 62 | **Процессоры** должны быть унаследованы от этого класса. Они позволяют преобразовывать таблицу итогового отчета 63 | (например, добавлять столбцы с подсчетом параметров, преобразовывать типы данных и т.д.). Для этого в них необходимо 64 | переопределить метод `process(df)`. 65 | 66 | Пример реализованного процессора: `BalanceProcessor`. 67 | 68 | #### `BalanceCalculator` 69 | **Калькуляторы** должны быть унаследованы от этого класса. Они позволяют строить из отчета короткую сводку с важными 70 | показателями (например, максимальная потеря за время торгов, итоговый заработок и т.д.). Для этого в них необходимо 71 | переопределить метод `calculate(df)`. 72 | 73 | Пример реализованного калькулятора: `BalanceCalculator`. 74 | 75 | ### `robotlib/strategy.py` 76 | #### `TradeStrategyBase` 77 | Абстрактный класс (интерфейс) торговой стратегии. Он обрабатывает обновления данных, предоставляемые роботом и 78 | возвращает результат - торговое поручение, которое необходимо реализовать роботу, или список тех, которые надо отменить. 79 | Для этого используется метод `decide(market_data, params)`. 80 | 81 | Бэктест стратегии работает аналогичным образом, за исключением того, что в нем предоставляются только данные свечей. Для 82 | него необходимо реализовать метод `decide_by_candle(candle, params)`. 83 | 84 | Примеры реализованной стратегии: `RandomStrategy`, `MAEStrategy` ([описание](#_4)). 85 | 86 | ### `robotlib/money.py` 87 | Содержит вспомогательный класс `Money`. Он полностью аналогичен классу `Quotation` из API Тинькофф Инвестиций, но 88 | с реализованными операторами сложения, вычитания и умножения на число, а также методы преобразования в / из `int`, 89 | `float`, `tinkoff.invest.Quotation`, `tinkoff.invest.MoneyValue`. 90 | 91 | ### `rootlib/visualization.py` 92 | Позволяет подключить наглядную визуализацию курса ценной бумаги, а так же 93 | покупок и продаж робота в произвольную стратегию. Для этого необходимо созать 94 | объект класса `Visualizer`, после чего добавлять в него все обновления цены 95 | методом `add_price(time, price)`, покупки и продажи методами `add_buy(time)`, 96 | `add_sell(time)` соответственно. -------------------------------------------------------------------------------- /docs/robot.md: -------------------------------------------------------------------------------- 1 | # Модуль robot 2 | 3 | Содержит классы, непосредственно связанные с торговым роботом: 4 | 5 | * [TradingRobot](#tradingrobot) - торговый робот; 6 | * [TradingRobotFactory](#tradingrobotfactory) - фабрика торговых роботов, позволяет просто создавать робота. 7 | 8 | **Внимание!** Рекомендуется не создавать робота вручную, а использовать для этого фабрику, так как фабрика умеет 9 | получать сведения об аккаунте и инструменте непосредственно из API, и не требует передачи этих данных в конструктор. 10 | При необходимости точечной настройки робота (например, ограничение баланса, доступного для траты) возможно 11 | отредактировать параметры робота после его создания. 12 | 13 | На текущий момент один робот ограничен одним аккаунтом и одним видом ценных бумаг. При желании настроить торговлю 14 | одновременно несколькими бумагами и / или с нескольких аккаунтов, рекомендуется создать и параллельно запустить 15 | нескольких роботов. 16 | 17 | ## TradingRobot 18 | 19 | Основной класс, торговый робот. Содержит в себе всю логику взаимодействия с API: создание и отмена торговых поручений, 20 | подписка на обновления и тд. 21 | 22 | ### Методы 23 | 24 | #### __init__ 25 | *Входные данные*: 26 | 27 | | Field | Type | Description | 28 | |------------------|---------------------------|-----------------------------------------------------------------| 29 | | token | str | Токен API Тинькофф Инвестиций | 30 | | account_id | str | ID аккаунта, с которого будет вестись торговля. | 31 | | sandbox_mode | bool | True для запуска робота в песочнице, False для "боевого" режима | 32 | | trade_strategy | TradeStrategyBase | Стратегия торгового робота | 33 | | trade_statistics | TradeStatisticsAnalyzer | Анализатор статистики робота | 34 | | instrument_info | tinkoff.invest.Instrument | Информация о торгуемых ценных бумагах | 35 | | logger | loggig.Logger | Логгер | 36 | 37 | *Выходные данные*: `TradingRobot`. 38 | 39 | #### trade 40 | Запуск торгового алгоритма. 41 | 42 | Метод загружает в стратегию исторические данные и подписывается на необходимые обновления биржевых данных. 43 | При получении обновления, передает его стретагии и действует согласно ее распоряжению. 44 | 45 | *Выходные данные*: `TradeStatisticsAnalyzer` - статистика робота. 46 | 47 | #### backtest 48 | Тестирование торговой стратегии на исторических данных. 49 | 50 | Метод получает из API данные за два последовательных периода длиной `train_duration` и `test_duration`. 51 | Обучающие данные загружает в робота в качестве исторических данных, после чего последовательно передает ему 52 | тестовые данные в качестве текущих биржевых данных. Все торговые поручения стратегии записывает в статистику, 53 | предоставляемую на выходе для анализа. 54 | 55 | *Входные данные*: 56 | 57 | | Field | Type | Description | 58 | |------------------|---------------------------|------------------------------------------| 59 | | initial_params | TradeStrategyParams | Изначальные параметры торговой стратегии | 60 | | test_duration | datetime.timedelta | Длительность тестового периода | 61 | | train_duration | datetime.timedelta | Длительность обучающего периода | 62 | 63 | *Выходные данные*: `TradeStatisticsAnalyzer` - статистика робота. 64 | 65 | #### to_money_value 66 | Преобразовывает значение в MoneyValue. 67 | 68 | *Входные данные*: 69 | 70 | | Field | Type | Description | 71 | |----------|------|-------------------------------------------------| 72 | | currency | str | Валюта, в которой необходимо вернуть MoneyValue | 73 | 74 | *Выходные данные*: MoneyValue. 75 | 76 | ## TradingRobotFactory 77 | 78 | Фабрика торговых роботов. 79 | 80 | ### Методы 81 | 82 | #### __init__ 83 | Во входных данных обязательно должны быть представлены либо `figi`, либо `ticker` и `class_code`. 84 | 85 | *Входные данные*: 86 | 87 | | Field | Type | Description | 88 | |--------------|---------------|-------------------------------------------------| 89 | | token | str | Токен API Тинькофф Инвестиций | 90 | | account_id | str | ID аккаунта, с которого будет вестись торговля. | 91 | | figi | Optional[str] | FIGI торгового инструмента | 92 | | ticker | Optional[str] | Тикер торгового инструмента | 93 | | class_code | Optional[str] | class_code торгового инструмента | 94 | | logger_level | Optional[str] | Уровень логирования. По умолчанию INFO | 95 | 96 | *Выходные данные*: `TradingRobotFactory`. 97 | 98 | #### setup_logger 99 | Настройка логгера. 100 | 101 | *Входные данные*: 102 | 103 | | Field | Type | Description | 104 | |--------------|------|---------------------| 105 | | logger_level | str | Уровень логирования | 106 | 107 | *Выходные данные*: `logging.Logger`. 108 | 109 | #### create_robot 110 | Создание торгового робота для инструмента, указанного в параметрах фабрики. 111 | 112 | *Входные данные*: 113 | 114 | | Field | Type | Description | 115 | |----------------|-------------------|-----------------------------------------------------| 116 | | trade_strategy | TradeStrategyBase | Торговая стратегия | 117 | | sandbox_mode | bool | Режим торговли (True - песочница, False - "боевой") | 118 | 119 | *Выходные данные*: `TradingRobot`. 120 | 121 | ## Примеры использования 122 | 123 | См. файл [main.py](https://github.com/karpp/investRobot/blob/master/main.py). 124 | -------------------------------------------------------------------------------- /docs/strategy.md: -------------------------------------------------------------------------------- 1 | # Модуль strategy 2 | 3 | Содержит классы стратегии торгового робота. 4 | 5 | ## TradeStrategyBase 6 | 7 | Интерфейс стратегии торгового робота. Содержит в себе логику обработки обновлений биржевых данных. 8 | Пользователь может использовать одну из представленных реализаций 9 | ([RandomStrategy](#randomstrategy-), [MAEStrategy](#maestrategy-)) или реализовать собственную. 10 | 11 | Торговый робот использует [bidirectional-stream](https://tinkoff.github.io/investAPI/head-marketdata/#bidirectional-stream) 12 | Тинькофф Инвестиций для получения информации о событиях. Для настройки событий, на которые стратегии необходима подписка 13 | следует использовать параметры стратегии `candle_subscription_interval`, `order_book_subscription_depth`, `trades_subscription` 14 | 15 | Подробнее см. [примеры использования](#_2). 16 | 17 | ### Представленные реализации 18 | 19 | #### `RandomStrategy` - Случайная стратегия 20 | С каждым обновлением данных принимает решение о покупке / продаже рандомного числа лотов исходя из параметров, имеющихся 21 | лотов и баланса. Не имеет потенциала, может использоваться для тестирования и в качестве примера стратегии. 22 | 23 | #### `MAEStrategy` - Стратегия скользящего среднего 24 | Данная стратегия основана на индикаторе скользящей средней. 25 | Раз в минуту (при получении новой минутной свечи) рассчитывает два скользящих средних, длины которого можно задать 26 | в конструкторе. 27 | При изменении знака их разницы (пересечении линии скользящих средних) считает, 28 | что текущий тренд цены изменился и отдает распоряжение на покупку / продажу, если "короткое" среднее выше или ниже 29 | "длинного" соответственно. Покупает и продает каждый раз фиксированное число, изначально заданное в конструкторе, 30 | при условии, что это возможно. 31 | 32 | 33 | ### Свойства 34 | 35 | | Field | Type | Description | 36 | |-------------------------------|----------------------------------------------|--------------------------------------| 37 | | candle_subscription_interval | tinkoff.invest.SubscriptionInterval | Период свечей для подписки | 38 | | order_book_subscription_depth | Optional[int] | Глубина стакана для подписки | 39 | | trades_subscription | bool | Подписка на обезличенные операции | 40 | | strategy_id | str | id стратегии (используется логгером) | 41 | 42 | ### Методы 43 | 44 | #### load_instrument_info 45 | Запись информации об инструменте. Метод вызывается классом `TradeRobotFabric`. 46 | 47 | *Входные данные*: 48 | 49 | | Field | Type | Description | 50 | |-----------------|---------------------------|---------------------------| 51 | | instrument_info | tinkoff.invest.Instrument | Информация об инструменте | 52 | 53 | 54 | #### load_candles 55 | Загрузка исторических свечей. Вызывается роботом при начале торгов. Может быть переопределена пользователем для 56 | настройки параметров стратегии. 57 | 58 | *Входные данные*: 59 | 60 | | Field | Type | Description | 61 | |---------|-------------------------------------|----------------------------| 62 | | candles | list[tinkoff.invest.HistoricCandle] | Список исторических свечей | 63 | 64 | #### decide 65 | Данный метод вызывается при получении новых биржевых данных. Возвращает объект, содержащий поручения торговому роботу. 66 | 67 | *Входные данные*: 68 | 69 | | Field | Type | Description | 70 | |-------------|---------------------------------------------|--------------------------| 71 | | market_data | tinkoff.invest.MarketDataResponse | Биржевые данные | 72 | | params | [TradeStrategyParams](#tradestrategyparams) | Текущие параметры робота | 73 | 74 | *Выходные данные*: [StrategyDecision](#strategydecision) - решения о действиях торгового робота. 75 | 76 | #### decide_by_candle 77 | Данный метод аналогичен методу decide, однако у него входные данные содержат только обновления свечей. Данный метод 78 | необходим для бэктеста стратегии, так как получение исторических данных по стаканам и обезличенным операциям не 79 | предоставляется возможным. 80 | 81 | *Входные данные*: 82 | 83 | | Field | Type | Description | 84 | |--------|---------------------------------------------|--------------------------| 85 | | candle | tinkoff.invest.MarketDataResponse | Свеча | 86 | | params | [TradeStrategyParams](#tradestrategyparams) | Текущие параметры робота | 87 | 88 | *Выходные данные*: [StrategyDecision](#strategydecision) - решения о действиях торгового робота. 89 | 90 | ## Структуры данных 91 | 92 | ### TradeStrategyParams 93 | Структура данных, передаваемая на вход стратегии при обновлении данных. 94 | 95 | | Field | Type | Description | 96 | |--------------------|---------------------------------|----------------------------------------| 97 | | instrument_balance | int | Количество лотов инструмента на счету | 98 | | currency_balance | float | Баланс счета | 99 | | pending_orders | list[tinkoff.invest.OrderState] | Список нереализованных биржевых заявок | 100 | 101 | ### RobotTradeOrder 102 | Структура данных, содержащая получение торговому роботу на выставление рыночной заявки. 103 | 104 | | Field | Type | Description | 105 | |------------|-------------------------------|----------------------------------------------| 106 | | quantity | int | Количество лотов | 107 | | direction | tinkoff.invest.OrderDirection | Направление заявки (покупка / продажа) | 108 | | prive | Optional[Money] | Цена заявки (при отсутствии - рыночная цена) | 109 | | order_type | tinkoff.invest.OrderType | Тип заявки | 110 | 111 | ### StrategyDecision 112 | Структура данных, возвращаемая стратегией и содержащая решение о действиях торгового робота. 113 | 114 | | Field | Type | Description | 115 | |-------------------|---------------------------|-----------------------------------------------------| 116 | | robot_trade_order | Optional[RobotTradeOrder] | Поручение роботу | 117 | | cancel_orders | list[OrderState] | Список биржевых заявок, которые необходимо отменить | 118 | -------------------------------------------------------------------------------- /docs/stats.md: -------------------------------------------------------------------------------- 1 | # Модуль stats 2 | 3 | Содержит классы для ведения статистики торгового робота. 4 | 5 | ## TradeStatisticsAnalyzer 6 | 7 | Основной класс, используемый для записи, хранения и получения статистики, а также генерации отчетов и коротких сводок. 8 | 9 | Класс предоставляет возможность преобразовать формат отчета, а также сгенерировать короткую сводку. Для этого необходимо 10 | реализовать классы, унаследованные от `TradeStatisticsProcessorBase` и `TradeStatisticsCalculatorBase` соответственно 11 | и передать объекты этих классов в параметры функции `get_report`. Подробнее см. [примеры использования](#_2). 12 | 13 | ### Методы 14 | 15 | #### __init__ 16 | *Входные данные*: 17 | 18 | | Field | Type | Description | 19 | |-----------------|---------------------------|-------------------------------------------------------------| 20 | | positions | int | Значение | 21 | | money | float | Значение nano (при использовании необходимо value типа int) | 22 | | instrument_info | tinkoff.invest.Instrument | Информация об инструменте тоговли | 23 | | logger | logging.Logger | Логгер | 24 | 25 | *Выходные данные*: `TradeStatisticsAnalyzer`. 26 | 27 | 28 | #### add_trade 29 | Запись операции в статистику. Этот метод в основном используется роботом, **не рекомендуется** вызывать его самостоятельно. 30 | 31 | *Входные данные*: 32 | 33 | | Field | Type | Description | 34 | |-------|---------------------------|-------------| 35 | | trade | tinkoff.invest.OrderState | Операция | 36 | 37 | 38 | #### cancel_order 39 | Отмена операции, удаление из статистики. Этот метод в основном используется роботом, **не рекомендуется** вызывать его самостоятельно. 40 | 41 | *Входные данные*: 42 | 43 | | Field | Type | Description | 44 | |----------|------|-------------| 45 | | order_id | int | ID операции | 46 | 47 | #### get_positions 48 | Получение количества ценных бумаг на счету на текущий момент. 49 | 50 | *Выходные данные*: `int`, количество бумаг. 51 | 52 | #### get_money 53 | Получение баланса на текущий момент. 54 | 55 | *Выходные данные*: `float`, баланс. 56 | 57 | #### get_pending_orders 58 | Получение списка нереализованных торговых заявок. 59 | 60 | *Выходные данные*: `list[tinkoff.invest.OrderState]`, список заявок. 61 | 62 | #### save_to_file 63 | Сохранение статистики в файл. 64 | 65 | *Входные данные*: 66 | 67 | | Field | Type | Description | 68 | |----------|------|----------------| 69 | | filename | str | Название файла | 70 | 71 | #### load_from_file 72 | Чтение статистики из файла. 73 | 74 | *Входные данные*: 75 | 76 | | Field | Type | Description | 77 | |----------|------|----------------| 78 | | filename | str | Название файла | 79 | 80 | *Выходные данные*: `TradeStatisticsAnalyzer` 81 | 82 | #### add_backtest_trade 83 | Запись в статистику операции из бэктеста. 84 | 85 | *Входные данные*: 86 | 87 | | Field | Type | Description | 88 | |-----------|-------------------------------|--------------------| 89 | | quantity | int | Количество лотов | 90 | | price | Quotation | Цена лота | 91 | | direction | tinkoff.invest.OrderDirection | Направление сделки | 92 | 93 | 94 | #### get_report 95 | Получение отчета о статистике. 96 | 97 | Метод собирает датафрейм с полной статистикой об операциях, после чего последовательно 98 | запускает на нем пользовательские обработчики. Для получения краткого отчета запускаются пользовательские генераторы 99 | краткой сводки, и их результаты объединяются в один dict. 100 | 101 | *Входные данные*: 102 | 103 | | Field | Type | Description | 104 | |-------------|-------------------------------------|---------------------------| 105 | | processors | list[TradeStatisticsProcessorBase] | Обработчики статистики | 106 | | calculators | list[TradeStatisticsCalculatorBase] | Генераторы краткой сводки | 107 | 108 | *Выходные данные*: `dict[str, any], pandas.DataFrame`: словарь с краткой сводкой, полная статистика по операциям. 109 | 110 | ### Примеры использования 111 | 112 | #### Генерация отчета 113 | ```python 114 | import pandas as pd 115 | from robotlib.stats import TradeStatisticsAnalyzer, TradeStatisticsProcessorBase, TradeStatisticsCalculatorBase 116 | 117 | class BalanceProcessor(TradeStatisticsProcessorBase): 118 | def process(self, df: pd.DataFrame) -> pd.DataFrame: 119 | df['balance'] = -(df['total_order_amount'] * df['sign']).cumsum() 120 | df['instrument_balance'] = (df['lots_executed'] * df['sign']).cumsum() 121 | return df 122 | 123 | 124 | class BalanceCalculator(TradeStatisticsCalculatorBase): 125 | def calculate(self, df: pd.DataFrame) -> dict[str, any]: 126 | final_balance = df['balance'][len(df) - 1] 127 | final_instrument_balance = df['instrument_balance'][len(df) - 1] 128 | final_price = df['average_position_price'][len(df) - 1] 129 | return { 130 | 'final_balance': final_balance, 131 | 'max_loss': -df['balance'].min(), 132 | 'final_instrument_balance': final_instrument_balance, 133 | 'income': final_balance + final_instrument_balance * final_price # todo: * instrument_info.lot 134 | } 135 | 136 | 137 | stats: TradeStatisticsAnalyzer 138 | # предположим, что эта статистика уже содержит данные 139 | 140 | short_summary, full_report = stats.get_report(processors=[BalanceProcessor()], calculators=[BalanceCalculator()]) 141 | 142 | print(short_summary) 143 | # output: {'final_balance': -1764.8, 'max_loss': 1889.2, 'final_instrument_balance': 1, 'income': -177.39999999999986} 144 | print(full_report) 145 | # output: dataframe with all the trades 146 | ``` 147 | 148 | ## TradeStatisticsProcessorBase 149 | 150 | Интерфейс, который необходимо реализовать при необходимости преобразования отчета. 151 | 152 | ### Методы 153 | 154 | #### process 155 | 156 | Преобразования отчета 157 | 158 | *Входные данные*: 159 | 160 | | Field | Type | Description | 161 | |-------|------------------|--------------| 162 | | df | pandas.DataFrame | Полный отчет | 163 | 164 | *Выходные данные*: `pandas.DataFrame`: преобразованный отчет. 165 | 166 | ## TradeStatisticsCalculatorBase 167 | 168 | Интерфейс, который необходимо реализовать для преобразования краткой сводки. 169 | 170 | ### Методы 171 | 172 | #### calculate 173 | 174 | *Входные данные*: 175 | 176 | | Field | Type | Description | 177 | |-------|------------------|--------------| 178 | | df | pandas.DataFrame | Полный отчет | 179 | 180 | *Выходные данные*: `dict[str, any]`: словарь ключ-значение, содержащий краткую сводку. -------------------------------------------------------------------------------- /robotlib/stats.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import logging 5 | import pickle 6 | import uuid 7 | 8 | from abc import ABC, abstractmethod 9 | from dataclasses import asdict 10 | 11 | import pandas as pd 12 | 13 | from tinkoff.invest import OrderState, Instrument, OrderDirection, Quotation, MoneyValue, OrderExecutionReportStatus, \ 14 | OrderType 15 | 16 | from robotlib.money import Money 17 | 18 | 19 | class TradeStatisticsAnalyzer: 20 | PENDING_ORDER_STATUSES = [ 21 | OrderExecutionReportStatus.EXECUTION_REPORT_STATUS_NEW, 22 | OrderExecutionReportStatus.EXECUTION_REPORT_STATUS_PARTIALLYFILL 23 | ] 24 | 25 | trades: dict[str, OrderState] 26 | positions: int 27 | money: float 28 | instrument_info: Instrument 29 | logger: logging.Logger 30 | 31 | def __init__(self, positions: int, money: float, instrument_info: Instrument, logger: logging.Logger): 32 | self.trades = {} 33 | self.positions = positions 34 | self.money = money 35 | self.instrument_info = instrument_info 36 | self.logger = logger 37 | 38 | def add_trade(self, trade: OrderState) -> None: 39 | self.logger.debug(f'Updating balance. Current state: [positions={self.positions} money={self.money}]. ' 40 | f'trade: {trade}') 41 | 42 | if trade.order_id in self.trades: 43 | trade.direction = self.trades[trade.order_id].direction 44 | sign = 1 if trade.direction == OrderDirection.ORDER_DIRECTION_BUY else -1 45 | self.positions += (trade.lots_executed - self.trades[trade.order_id].lots_executed) * sign 46 | self.money -= (self.convert_from_quotation(trade.total_order_amount) 47 | - self.convert_from_quotation(self.trades[trade.order_id].total_order_amount)) * sign 48 | else: 49 | sign = 1 if trade.direction == OrderDirection.ORDER_DIRECTION_BUY else -1 50 | self.positions += trade.lots_executed * sign 51 | self.money -= self.convert_from_quotation(trade.total_order_amount) * sign 52 | 53 | self.trades[trade.order_id] = trade 54 | self.logger.debug(f'Updating balance. New state: [positions={self.positions} money={self.money}]') 55 | 56 | def cancel_order(self, order_id: str): 57 | self.trades.pop(order_id) 58 | 59 | def get_positions(self) -> int: 60 | return self.positions 61 | 62 | def get_money(self) -> float: 63 | return self.money 64 | 65 | def get_pending_orders(self) -> list[OrderState]: 66 | return [trade for trade in self.trades.values() if trade.execution_report_status in self.PENDING_ORDER_STATUSES] 67 | 68 | def save_to_file(self, filename: str) -> None: 69 | with open(filename, 'wb') as file: 70 | pickle.dump(obj=self, file=file, protocol=pickle.HIGHEST_PROTOCOL) 71 | 72 | @staticmethod 73 | def load_from_file(filename: str) -> TradeStatisticsAnalyzer: 74 | with open(filename, 'rb') as file: 75 | return pickle.load(file) 76 | 77 | @staticmethod 78 | def convert_from_quotation(amount: Quotation | MoneyValue) -> float | None: 79 | if amount is None: 80 | return None 81 | return amount.units + amount.nano / (10 ** 9) 82 | 83 | def add_backtest_trade(self, quantity: int, price: Quotation, direction: OrderDirection): 84 | if quantity == 0: 85 | return 86 | price_money = MoneyValue('RUB', price.units, price.nano) 87 | zero_money = MoneyValue('RUB', 0, 0) 88 | self.add_trade(OrderState( 89 | order_id=str(uuid.uuid4()), 90 | execution_report_status=OrderExecutionReportStatus.EXECUTION_REPORT_STATUS_FILL, 91 | lots_requested=quantity, 92 | lots_executed=quantity, 93 | initial_order_price=price_money, 94 | executed_order_price=price_money, 95 | total_order_amount=(Money(price) * quantity).to_money_value('RUB'), 96 | average_position_price=price_money, 97 | initial_commission=zero_money, 98 | executed_commission=zero_money, 99 | figi=self.instrument_info.figi, 100 | direction=direction, 101 | initial_security_price=price_money, 102 | stages=[], 103 | service_commission=zero_money, 104 | currency=price_money.currency, 105 | order_type=OrderType.ORDER_TYPE_MARKET, 106 | order_date=datetime.datetime.now() 107 | )) 108 | 109 | def get_report(self, processors: list[TradeStatisticsProcessorBase] = None, 110 | calculators: list[TradeStatisticsCalculatorBase] = None)\ 111 | -> tuple[dict[str, any], pd.DataFrame]: 112 | df = pd.DataFrame(map(asdict, self.trades.values())) # pylint:disable=invalid-name 113 | df['average_position_price'] = df['average_position_price'].apply(lambda x: x['units'] + x['nano'] / (10 ** 9)) 114 | df['total_order_amount'] = df['total_order_amount'].apply(lambda x: x['units'] + x['nano'] / (10 ** 9)) 115 | df['sign'] = 3 - df['direction'] * 2 116 | 117 | for processor in processors or []: 118 | df = processor.process(df) # pylint:disable=invalid-name 119 | 120 | stats = {} 121 | for calculator in calculators or []: 122 | stats |= calculator.calculate(df) 123 | 124 | return stats, df 125 | 126 | 127 | class TradeStatisticsProcessorBase(ABC): # pylint:disable=too-few-public-methods 128 | @abstractmethod 129 | def process(self, df: pd.DataFrame) -> pd.DataFrame: # pylint:disable=invalid-name 130 | raise NotImplementedError() 131 | 132 | 133 | class TradeStatisticsCalculatorBase(ABC): # pylint:disable=too-few-public-methods 134 | @abstractmethod 135 | def calculate(self, df: pd.DataFrame) -> dict[str, any]: # pylint:disable=invalid-name 136 | raise NotImplementedError() 137 | 138 | 139 | class BalanceProcessor(TradeStatisticsProcessorBase): # pylint:disable=too-few-public-methods 140 | def process(self, df: pd.DataFrame) -> pd.DataFrame: 141 | df['balance'] = -(df['total_order_amount'] * df['sign']).cumsum() 142 | df['instrument_balance'] = (df['lots_executed'] * df['sign']).cumsum() 143 | return df 144 | 145 | 146 | class BalanceCalculator(TradeStatisticsCalculatorBase): # pylint:disable=too-few-public-methods 147 | def calculate(self, df: pd.DataFrame) -> dict[str, any]: 148 | final_balance = df['balance'][len(df) - 1] 149 | final_instrument_balance = df['instrument_balance'][len(df) - 1] 150 | final_price = df['average_position_price'][len(df) - 1] 151 | return { 152 | 'final_balance': final_balance, 153 | 'max_loss': -df['balance'].min(), 154 | 'final_instrument_balance': final_instrument_balance, 155 | 'income': final_balance + final_instrument_balance * final_price 156 | } 157 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/python,pycharm+all,virtualenv,macos 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=python,pycharm+all,virtualenv,macos 4 | 5 | ### macOS ### 6 | # General 7 | .DS_Store 8 | .AppleDouble 9 | .LSOverride 10 | 11 | # Icon must end with two \r 12 | Icon 13 | 14 | 15 | # Thumbnails 16 | ._* 17 | 18 | # Files that might appear in the root of a volume 19 | .DocumentRevisions-V100 20 | .fseventsd 21 | .Spotlight-V100 22 | .TemporaryItems 23 | .Trashes 24 | .VolumeIcon.icns 25 | .com.apple.timemachine.donotpresent 26 | 27 | # Directories potentially created on remote AFP share 28 | .AppleDB 29 | .AppleDesktop 30 | Network Trash Folder 31 | Temporary Items 32 | .apdisk 33 | 34 | ### macOS Patch ### 35 | # iCloud generated files 36 | *.icloud 37 | 38 | ### PyCharm+all ### 39 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 40 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 41 | 42 | # User-specific stuff 43 | .idea/**/workspace.xml 44 | .idea/**/tasks.xml 45 | .idea/**/usage.statistics.xml 46 | .idea/**/dictionaries 47 | .idea/**/shelf 48 | 49 | # AWS User-specific 50 | .idea/**/aws.xml 51 | 52 | # Generated files 53 | .idea/**/contentModel.xml 54 | 55 | # Sensitive or high-churn files 56 | .idea/**/dataSources/ 57 | .idea/**/dataSources.ids 58 | .idea/**/dataSources.local.xml 59 | .idea/**/sqlDataSources.xml 60 | .idea/**/dynamic.xml 61 | .idea/**/uiDesigner.xml 62 | .idea/**/dbnavigator.xml 63 | 64 | # Gradle 65 | .idea/**/gradle.xml 66 | .idea/**/libraries 67 | 68 | # Gradle and Maven with auto-import 69 | # When using Gradle or Maven with auto-import, you should exclude module files, 70 | # since they will be recreated, and may cause churn. Uncomment if using 71 | # auto-import. 72 | # .idea/artifacts 73 | # .idea/compiler.xml 74 | # .idea/jarRepositories.xml 75 | # .idea/modules.xml 76 | # .idea/*.iml 77 | # .idea/modules 78 | # *.iml 79 | # *.ipr 80 | 81 | # CMake 82 | cmake-build-*/ 83 | 84 | # Mongo Explorer plugin 85 | .idea/**/mongoSettings.xml 86 | 87 | # File-based project format 88 | *.iws 89 | 90 | # IntelliJ 91 | out/ 92 | 93 | # mpeltonen/sbt-idea plugin 94 | .idea_modules/ 95 | 96 | # JIRA plugin 97 | atlassian-ide-plugin.xml 98 | 99 | # Cursive Clojure plugin 100 | .idea/replstate.xml 101 | 102 | # SonarLint plugin 103 | .idea/sonarlint/ 104 | 105 | # Crashlytics plugin (for Android Studio and IntelliJ) 106 | com_crashlytics_export_strings.xml 107 | crashlytics.properties 108 | crashlytics-build.properties 109 | fabric.properties 110 | 111 | # Editor-based Rest Client 112 | .idea/httpRequests 113 | 114 | # Android studio 3.1+ serialized cache file 115 | .idea/caches/build_file_checksums.ser 116 | 117 | ### PyCharm+all Patch ### 118 | # Ignore everything but code style settings and run configurations 119 | # that are supposed to be shared within teams. 120 | 121 | .idea/* 122 | 123 | !.idea/codeStyles 124 | !.idea/runConfigurations 125 | 126 | ### Python ### 127 | # Byte-compiled / optimized / DLL files 128 | __pycache__/ 129 | *.py[cod] 130 | *$py.class 131 | 132 | # C extensions 133 | *.so 134 | 135 | # Distribution / packaging 136 | .Python 137 | build/ 138 | develop-eggs/ 139 | dist/ 140 | downloads/ 141 | eggs/ 142 | .eggs/ 143 | lib/ 144 | lib64/ 145 | parts/ 146 | sdist/ 147 | var/ 148 | wheels/ 149 | share/python-wheels/ 150 | *.egg-info/ 151 | .installed.cfg 152 | *.egg 153 | MANIFEST 154 | 155 | # PyInstaller 156 | # Usually these files are written by a python script from a template 157 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 158 | *.manifest 159 | *.spec 160 | 161 | # Installer logs 162 | pip-log.txt 163 | pip-delete-this-directory.txt 164 | 165 | # Unit test / coverage reports 166 | htmlcov/ 167 | .tox/ 168 | .nox/ 169 | .coverage 170 | .coverage.* 171 | .cache 172 | nosetests.xml 173 | coverage.xml 174 | *.cover 175 | *.py,cover 176 | .hypothesis/ 177 | .pytest_cache/ 178 | cover/ 179 | 180 | # Translations 181 | *.mo 182 | *.pot 183 | 184 | # Django stuff: 185 | *.log 186 | local_settings.py 187 | db.sqlite3 188 | db.sqlite3-journal 189 | 190 | # Flask stuff: 191 | instance/ 192 | .webassets-cache 193 | 194 | # Scrapy stuff: 195 | .scrapy 196 | 197 | # Sphinx documentation 198 | docs/_build/ 199 | 200 | # PyBuilder 201 | .pybuilder/ 202 | target/ 203 | 204 | # Jupyter Notebook 205 | .ipynb_checkpoints 206 | 207 | # IPython 208 | profile_default/ 209 | ipython_config.py 210 | 211 | # pyenv 212 | # For a library or package, you might want to ignore these files since the code is 213 | # intended to run in multiple environments; otherwise, check them in: 214 | # .python-version 215 | 216 | # pipenv 217 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 218 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 219 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 220 | # install all needed dependencies. 221 | #Pipfile.lock 222 | 223 | # poetry 224 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 225 | # This is especially recommended for binary packages to ensure reproducibility, and is more 226 | # commonly ignored for libraries. 227 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 228 | #poetry.lock 229 | 230 | # pdm 231 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 232 | #pdm.lock 233 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 234 | # in version control. 235 | # https://pdm.fming.dev/#use-with-ide 236 | .pdm.toml 237 | 238 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 239 | __pypackages__/ 240 | 241 | # Celery stuff 242 | celerybeat-schedule 243 | celerybeat.pid 244 | 245 | # SageMath parsed files 246 | *.sage.py 247 | 248 | # Environments 249 | .env 250 | .venv 251 | env/ 252 | venv/ 253 | ENV/ 254 | env.bak/ 255 | venv.bak/ 256 | 257 | # Spyder project settings 258 | .spyderproject 259 | .spyproject 260 | 261 | # Rope project settings 262 | .ropeproject 263 | 264 | # mkdocs documentation 265 | /site 266 | 267 | # mypy 268 | .mypy_cache/ 269 | .dmypy.json 270 | dmypy.json 271 | 272 | # Pyre type checker 273 | .pyre/ 274 | 275 | # pytype static type analyzer 276 | .pytype/ 277 | 278 | # Cython debug symbols 279 | cython_debug/ 280 | 281 | # PyCharm 282 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 283 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 284 | # and can be added to the global gitignore or merged into this file. For a more nuclear 285 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 286 | #.idea/ 287 | 288 | ### VirtualEnv ### 289 | # Virtualenv 290 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 291 | [Bb]in 292 | [Ii]nclude 293 | [Ll]ib 294 | [Ll]ib64 295 | [Ll]ocal 296 | [Ss]cripts 297 | pyvenv.cfg 298 | pip-selfcheck.json 299 | 300 | # End of https://www.toptal.com/developers/gitignore/api/python,pycharm+all,virtualenv,macos 301 | -------------------------------------------------------------------------------- /robotlib/strategy.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import math 3 | import random 4 | 5 | from abc import ABC, abstractmethod 6 | from dataclasses import dataclass, field 7 | 8 | from tinkoff.invest import ( 9 | Candle, 10 | HistoricCandle, 11 | Instrument, 12 | MarketDataResponse, 13 | OrderType, 14 | OrderDirection, 15 | OrderState, 16 | Quotation, 17 | SubscriptionInterval, 18 | ) 19 | from robotlib.money import Money 20 | from robotlib.vizualization import Visualizer 21 | 22 | 23 | @dataclass 24 | class TradeStrategyParams: 25 | instrument_balance: int 26 | currency_balance: float 27 | pending_orders: list[OrderState] 28 | 29 | 30 | @dataclass 31 | class RobotTradeOrder: 32 | quantity: int 33 | direction: OrderDirection 34 | price: Money | None = None 35 | order_type: OrderType = OrderType.ORDER_TYPE_MARKET 36 | 37 | 38 | @dataclass 39 | class StrategyDecision: 40 | robot_trade_order: RobotTradeOrder | None = None 41 | cancel_orders: list[OrderState] = field(default_factory=list) 42 | 43 | 44 | class TradeStrategyBase(ABC): 45 | instrument_info: Instrument 46 | 47 | @property 48 | @abstractmethod 49 | def candle_subscription_interval(self) -> SubscriptionInterval: 50 | return SubscriptionInterval.SUBSCRIPTION_INTERVAL_ONE_MINUTE 51 | 52 | @property 53 | @abstractmethod 54 | def order_book_subscription_depth(self) -> int | None: # set not None to subscribe robot to order book 55 | return None 56 | 57 | @property 58 | @abstractmethod 59 | def trades_subscription(self) -> bool: # set True to subscribe robot to trades stream 60 | return False 61 | 62 | @property 63 | @abstractmethod 64 | def strategy_id(self) -> str: 65 | """ 66 | string representing short strategy name for logger 67 | """ 68 | raise NotImplementedError() 69 | 70 | def load_instrument_info(self, instrument_info: Instrument): 71 | self.instrument_info = instrument_info 72 | 73 | def load_candles(self, candles: list[HistoricCandle]) -> None: 74 | """ 75 | Method used by robot to load historic data 76 | """ 77 | pass 78 | 79 | @abstractmethod 80 | def decide(self, market_data: MarketDataResponse, params: TradeStrategyParams) -> StrategyDecision: 81 | if market_data.candle: 82 | return self.decide_by_candle(market_data.candle, params) 83 | return StrategyDecision() 84 | 85 | @abstractmethod 86 | def decide_by_candle(self, candle: Candle | HistoricCandle, params: TradeStrategyParams) -> StrategyDecision: 87 | pass 88 | 89 | 90 | class RandomStrategy(TradeStrategyBase): 91 | request_candles: bool = True 92 | strategy_id: str = 'random' 93 | 94 | low: int 95 | high: int 96 | 97 | def __init__(self, low: int, high: int): 98 | self.low = low 99 | self.high = high 100 | 101 | def decide(self, market_data: MarketDataResponse, params: TradeStrategyParams) -> StrategyDecision: 102 | return self.decide_by_candle(market_data.candle, params) 103 | 104 | def decide_by_candle(self, candle: Candle | HistoricCandle, params: TradeStrategyParams) -> StrategyDecision: 105 | low = max(self.low, -params.instrument_balance) 106 | high = min(self.high, math.floor(params.currency_balance / self.convert_quotation(candle.close))) 107 | 108 | quantity = random.randint(low, high) 109 | direction = OrderDirection.ORDER_DIRECTION_BUY if quantity > 0 else OrderDirection.ORDER_DIRECTION_SELL 110 | 111 | return StrategyDecision(RobotTradeOrder(quantity=quantity, direction=direction)) 112 | 113 | @staticmethod 114 | def convert_quotation(amount: Quotation) -> float | None: 115 | if amount is None: 116 | return None 117 | return amount.units + amount.nano / (10 ** 9) 118 | 119 | 120 | class MAEStrategy(TradeStrategyBase): 121 | request_candles: bool = True 122 | strategy_id: str = 'mae' 123 | 124 | candle_subscription_interval: SubscriptionInterval = SubscriptionInterval.SUBSCRIPTION_INTERVAL_ONE_MINUTE 125 | order_book_subscription_depth = None 126 | trades_subscription = None 127 | 128 | short_len: int 129 | long_len: int 130 | trade_count: int 131 | prices = dict[datetime.datetime, Money] 132 | prev_sign: bool 133 | 134 | def __init__(self, short_len: int = 5, long_len: int = 20, trade_count: int = 1, visualizer: Visualizer = None): 135 | assert long_len > short_len 136 | self.short_len = short_len 137 | self.long_len = long_len 138 | self.trade_count = trade_count 139 | self.prices = {} 140 | self.visualizer = visualizer 141 | 142 | def load_candles(self, candles: list[HistoricCandle]) -> None: 143 | self.prices = {candle.time.replace(second=0, microsecond=0): Money(candle.close) 144 | for candle in candles[-self.long_len:]} 145 | self.prev_sign = self._long_avg() > self._short_avg() 146 | 147 | def decide(self, market_data: MarketDataResponse, params: TradeStrategyParams) -> StrategyDecision: 148 | return self.decide_by_candle(market_data.candle, params) 149 | 150 | def decide_by_candle(self, candle: Candle | HistoricCandle, params: TradeStrategyParams) -> StrategyDecision: 151 | time: datetime = candle.time.replace(second=0, microsecond=0) 152 | order: RobotTradeOrder | None = None 153 | if time not in self.prices: # make order only once a minute (when minutely candle is ready) 154 | sign = self._long_avg() > self._short_avg() 155 | if sign != self.prev_sign: 156 | if sign: 157 | if params.instrument_balance > 0: 158 | order = RobotTradeOrder(quantity=min(self.trade_count, params.instrument_balance), 159 | direction=OrderDirection.ORDER_DIRECTION_SELL) 160 | if self.visualizer: 161 | self.visualizer.add_sell(time) 162 | else: 163 | lot_price = Money(candle.close).to_float() * self.instrument_info.lot 164 | lots_available = int(params.currency_balance / lot_price) 165 | if params.currency_balance >= lot_price: 166 | order = RobotTradeOrder(quantity=min(self.trade_count, lots_available), 167 | direction=OrderDirection.ORDER_DIRECTION_BUY) 168 | if self.visualizer: 169 | self.visualizer.add_buy(time) 170 | 171 | self.prev_sign = sign 172 | self.prices[time] = Money(candle.close) 173 | if self.visualizer: 174 | self.visualizer.add_price(time, Money(candle.close).to_float()) 175 | self.visualizer.update_plot() 176 | 177 | return StrategyDecision(robot_trade_order=order) 178 | 179 | def get_prices_list(self) -> list[Money]: 180 | # sort by keys and then convert to a list of values 181 | return list(map(lambda x: x[1], sorted(self.prices.items(), key=lambda x: x[0]))) 182 | 183 | def _long_avg(self): 184 | return sum(float(price) for price in self.get_prices_list()[-self.long_len:]) / self.long_len 185 | 186 | def _short_avg(self): 187 | return sum(float(price) for price in self.get_prices_list()[-self.short_len:]) / self.short_len 188 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /robotlib/robot.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import sys 4 | import uuid 5 | 6 | from dataclasses import dataclass 7 | 8 | from tinkoff.invest import ( 9 | AccessLevel, 10 | AccountStatus, 11 | AccountType, 12 | Candle, 13 | CandleInstrument, 14 | CandleInterval, 15 | Client, 16 | InfoInstrument, 17 | Instrument, 18 | InstrumentIdType, 19 | MarketDataResponse, 20 | MoneyValue, 21 | OrderBookInstrument, 22 | OrderDirection, 23 | OrderExecutionReportStatus, 24 | OrderState, 25 | PostOrderResponse, 26 | Quotation, 27 | TradeInstrument, 28 | ) 29 | from tinkoff.invest.exceptions import InvestError 30 | from tinkoff.invest.services import MarketDataStreamManager, Services 31 | 32 | from robotlib.strategy import TradeStrategyBase, TradeStrategyParams, RobotTradeOrder 33 | from robotlib.stats import TradeStatisticsAnalyzer 34 | from robotlib.money import Money 35 | 36 | 37 | @dataclass 38 | class OrderExecutionInfo: 39 | direction: OrderDirection 40 | lots: int = 0 41 | amount: float = 0.0 42 | 43 | 44 | class TradingRobot: # pylint:disable=too-many-instance-attributes 45 | APP_NAME: str = 'karpp' 46 | 47 | token: str 48 | account_id: str 49 | trade_strategy: TradeStrategyBase 50 | trade_statistics: TradeStatisticsAnalyzer 51 | orders_executed: dict[str, OrderExecutionInfo] # order_id -> executed lots 52 | logger: logging.Logger 53 | instrument_info: Instrument 54 | sandbox_mode: bool 55 | 56 | def __init__(self, token: str, account_id: str, sandbox_mode: bool, trade_strategy: TradeStrategyBase, 57 | trade_statistics: TradeStatisticsAnalyzer, instrument_info: Instrument, logger: logging.Logger): 58 | self.token = token 59 | self.account_id = account_id 60 | self.trade_strategy = trade_strategy 61 | self.trade_statistics = trade_statistics 62 | self.orders_executed = {} 63 | self.logger = logger 64 | self.instrument_info = instrument_info 65 | self.sandbox_mode = sandbox_mode 66 | 67 | def trade(self) -> TradeStatisticsAnalyzer: 68 | self.logger.info('Starting trading') 69 | 70 | self.trade_strategy.load_candles( 71 | list(self._load_historic_data(datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=1)))) 72 | 73 | with Client(self.token, app_name=self.APP_NAME) as client: 74 | trading_status = client.market_data.get_trading_status(figi=self.instrument_info.figi) 75 | if not trading_status.market_order_available_flag: 76 | self.logger.warning('Market trading is not available now.') 77 | 78 | market_data_stream: MarketDataStreamManager = client.create_market_data_stream() 79 | if self.trade_strategy.candle_subscription_interval: 80 | market_data_stream.candles.subscribe([ 81 | CandleInstrument( 82 | figi=self.instrument_info.figi, 83 | interval=self.trade_strategy.candle_subscription_interval) 84 | ]) 85 | if self.trade_strategy.order_book_subscription_depth: 86 | market_data_stream.order_book.subscribe([ 87 | OrderBookInstrument( 88 | figi=self.instrument_info.figi, 89 | depth=self.trade_strategy.order_book_subscription_depth) 90 | ]) 91 | if self.trade_strategy.trades_subscription: 92 | market_data_stream.trades.subscribe([ 93 | TradeInstrument(figi=self.instrument_info.figi) 94 | ]) 95 | market_data_stream.info.subscribe([ 96 | InfoInstrument(figi=self.instrument_info.figi) 97 | ]) 98 | self.logger.debug(f'Subscribed to MarketDataStream, ' 99 | f'interval: {self.trade_strategy.candle_subscription_interval}') 100 | try: 101 | for market_data in market_data_stream: 102 | self.logger.debug(f'Received market_data {market_data}') 103 | if market_data.candle: 104 | self._on_update(client, market_data) 105 | if market_data.trading_status and market_data.trading_status.market_order_available_flag: 106 | self.logger.info(f'Trading is limited. Current status: {market_data.trading_status}') 107 | break 108 | except InvestError as error: 109 | self.logger.info(f'Caught exception {error}, stopping trading') 110 | market_data_stream.stop() 111 | return self.trade_statistics 112 | 113 | def backtest(self, initial_params: TradeStrategyParams, test_duration: datetime.timedelta, 114 | train_duration: datetime.timedelta = None) -> TradeStatisticsAnalyzer: 115 | 116 | trade_statistics = TradeStatisticsAnalyzer( 117 | positions=initial_params.instrument_balance, 118 | money=initial_params.currency_balance, 119 | instrument_info=self.instrument_info, 120 | logger=self.logger 121 | ) 122 | 123 | now = datetime.datetime.now(datetime.timezone.utc) 124 | if train_duration: 125 | train = self._load_historic_data(now - test_duration - train_duration, now - test_duration) 126 | self.trade_strategy.load_candles(list(train)) 127 | test = self._load_historic_data(now - test_duration) 128 | 129 | params = initial_params 130 | for candle in test: 131 | price = self.convert_from_quotation(candle.close) 132 | robot_decision = self.trade_strategy.decide_by_candle(candle, params) 133 | 134 | trade_order = robot_decision.robot_trade_order 135 | if trade_order: 136 | assert trade_order.quantity > 0 137 | if trade_order.direction == OrderDirection.ORDER_DIRECTION_SELL: 138 | assert trade_order.quantity >= params.instrument_balance, \ 139 | f'Cannot execute order {trade_order}. Params are {params}' # TODO: better logging 140 | params.instrument_balance -= trade_order.quantity 141 | params.currency_balance += trade_order.quantity * price * self.instrument_info.lot 142 | else: 143 | assert trade_order.quantity * self.instrument_info.lot * price <= params.currency_balance, \ 144 | f'Cannot execute order {trade_order}. Params are {params}' # TODO: better logging 145 | params.instrument_balance += trade_order.quantity 146 | params.currency_balance -= trade_order.quantity * price * self.instrument_info.lot 147 | 148 | trade_statistics.add_backtest_trade( 149 | quantity=trade_order.quantity, price=candle.close, direction=trade_order.direction) 150 | 151 | return trade_statistics 152 | 153 | @staticmethod 154 | def convert_from_quotation(amount: Quotation | MoneyValue) -> float | None: 155 | if amount is None: 156 | return None 157 | return amount.units + amount.nano / (10 ** 9) 158 | 159 | def _on_update(self, client: Services, market_data: MarketDataResponse): 160 | self._check_trade_orders(client) 161 | params = TradeStrategyParams(instrument_balance=self.trade_statistics.get_positions(), 162 | currency_balance=self.trade_statistics.get_money(), 163 | pending_orders=self.trade_statistics.get_pending_orders()) 164 | 165 | self.logger.debug(f'Received market_data {market_data}. Running strategy with params {params}') 166 | strategy_decision = self.trade_strategy.decide(market_data, params) 167 | self.logger.debug(f'Strategy decision: {strategy_decision}') 168 | 169 | if len(strategy_decision.cancel_orders) > 0: 170 | self._cancel_orders(client=client, orders=strategy_decision.cancel_orders) 171 | 172 | trade_order = strategy_decision.robot_trade_order 173 | if trade_order and self._validate_strategy_order(order=trade_order, candle=market_data.candle): 174 | self._post_trade_order(client=client, trade_order=trade_order) 175 | 176 | def _validate_strategy_order(self, order: RobotTradeOrder, candle: Candle): 177 | if order.direction == OrderDirection.ORDER_DIRECTION_BUY: 178 | price = order.price or Money(candle.close) 179 | total_cost = price * self.instrument_info.lot * order.quantity 180 | balance = self.trade_statistics.get_money() 181 | if total_cost.to_float() > self.trade_statistics.get_money(): 182 | self.logger.warning(f'Strategy decision cannot be executed. ' 183 | f'Requested buy cost: {total_cost}, balance: {balance}') 184 | return False 185 | else: 186 | instrument_balance = self.trade_statistics.get_positions() 187 | if order.quantity > instrument_balance: 188 | self.logger.warning(f'Strategy decision cannot be executed. ' 189 | f'Requested sell quantity: {order.quantity}, balance: {instrument_balance}') 190 | return False 191 | return True 192 | 193 | def _load_historic_data(self, from_time: datetime.datetime, to_time: datetime.datetime = None): 194 | try: 195 | with Client(self.token, app_name=self.APP_NAME) as client: 196 | yield from client.get_all_candles( 197 | from_=from_time, 198 | to=to_time, 199 | interval=CandleInterval.CANDLE_INTERVAL_1_MIN, 200 | figi=self.instrument_info.figi, 201 | ) 202 | except InvestError as error: 203 | self.logger.error(f'Failed to load historical data. Error: {error}') 204 | 205 | def _cancel_orders(self, client: Services, orders: list[OrderState]): 206 | for order in orders: 207 | try: 208 | client.orders.cancel_order(account_id=self.account_id, order_id=order.order_id) 209 | self.trade_statistics.cancel_order(order_id=order.order_id) 210 | except InvestError as error: 211 | self.logger.error(f'Failed to cancel order {order.order_id}. Error: {error}') 212 | 213 | def _post_trade_order(self, client: Services, trade_order: RobotTradeOrder) -> PostOrderResponse | None: 214 | try: 215 | if self.sandbox_mode: 216 | order = client.sandbox.post_sandbox_order( 217 | figi=self.instrument_info.figi, 218 | quantity=trade_order.quantity, 219 | price=trade_order.price.to_quotation() if trade_order.price is not None else None, 220 | direction=trade_order.direction, 221 | account_id=self.account_id, 222 | order_type=trade_order.order_type, 223 | order_id=str(uuid.uuid4()) 224 | ) 225 | else: 226 | order = client.orders.post_order( 227 | figi=self.instrument_info.figi, 228 | quantity=trade_order.quantity, 229 | price=trade_order.price.to_quotation() if trade_order.price is not None else None, 230 | direction=trade_order.direction, 231 | account_id=self.account_id, 232 | order_type=trade_order.order_type, 233 | order_id=str(uuid.uuid4()) 234 | ) 235 | except InvestError as error: 236 | self.logger.error(f'Posting trade order failed :(. Order: {trade_order}; Exception: {error}') 237 | return 238 | self.logger.info(f'Placed trade order {order}') 239 | self.orders_executed[order.order_id] = OrderExecutionInfo(direction=trade_order.direction) 240 | self.trade_statistics.add_trade(order) 241 | return order 242 | 243 | def _check_trade_orders(self, client: Services): 244 | self.logger.debug(f'Updating trade orders info. Current trade orders num: {len(self.orders_executed)}') 245 | orders_executed = list(self.orders_executed.items()) 246 | for order_id, execution_info in orders_executed: 247 | if self.sandbox_mode: 248 | order_state = client.sandbox.get_sandbox_order_state( 249 | account_id=self.account_id, order_id=order_id 250 | ) 251 | else: 252 | order_state = client.orders.get_order_state( 253 | account_id=self.account_id, order_id=order_id 254 | ) 255 | 256 | self.trade_statistics.add_trade(trade=order_state) 257 | match order_state.execution_report_status: 258 | case OrderExecutionReportStatus.EXECUTION_REPORT_STATUS_FILL: 259 | self.logger.info(f'Trade order {order_id} has been FULLY FILLED') 260 | self.orders_executed.pop(order_id) 261 | case OrderExecutionReportStatus.EXECUTION_REPORT_STATUS_REJECTED: 262 | self.logger.warning(f'Trade order {order_id} has been REJECTED') 263 | self.orders_executed.pop(order_id) 264 | case OrderExecutionReportStatus.EXECUTION_REPORT_STATUS_CANCELLED: 265 | self.logger.warning(f'Trade order {order_id} has been CANCELLED') 266 | self.orders_executed.pop(order_id) 267 | case OrderExecutionReportStatus.EXECUTION_REPORT_STATUS_PARTIALLYFILL: 268 | self.logger.info(f'Trade order {order_id} has been PARTIALLY FILLED') 269 | self.orders_executed[order_id] = OrderExecutionInfo(lots=order_state.lots_executed, 270 | amount=order_state.total_order_amount, 271 | direction=order_state.direction) 272 | case _: 273 | self.logger.debug(f'No updates on order {order_id}') 274 | 275 | self.logger.debug(f'Successfully updated trade orders. New trade orders num: {len(self.orders_executed)}') 276 | 277 | 278 | class TradingRobotFactory: 279 | APP_NAME = 'karpp' 280 | instrument_info: Instrument 281 | token: str 282 | account_id: str 283 | logger: logging.Logger 284 | sandbox_mode: bool 285 | 286 | def __init__(self, token: str, account_id: str, figi: str = None, # pylint:disable=too-many-arguments 287 | ticker: str = None, class_code: str = None, logger_level: int | str = 'INFO'): 288 | self.instrument_info = self._get_instrument_info(token, figi, ticker, class_code).instrument 289 | self.token = token 290 | self.account_id = account_id 291 | self.logger = self.setup_logger(logger_level) 292 | self.sandbox_mode = self._validate_account(token, account_id, self.logger) 293 | 294 | def setup_logger(self, logger_level: int | str): 295 | logger = logging.getLogger(f'robot.{self.instrument_info.ticker}') 296 | logger.setLevel(logger_level) 297 | formatter = logging.Formatter(fmt=('%(asctime)s %(levelname)s: %(message)s')) # todo: fixit 298 | handler = logging.StreamHandler(stream=sys.stderr) 299 | handler.setFormatter(formatter) 300 | logger.addHandler(handler) 301 | return logger 302 | 303 | def create_robot(self, trade_strategy: TradeStrategyBase, sandbox_mode: bool = True) -> TradingRobot: 304 | money, positions = self._get_current_postitions() 305 | trade_strategy.load_instrument_info(self.instrument_info) 306 | stats = TradeStatisticsAnalyzer( 307 | positions=positions, 308 | money=money.to_float(), # todo: change to Money 309 | instrument_info=self.instrument_info, 310 | logger=self.logger.getChild(trade_strategy.strategy_id).getChild('stats') 311 | ) 312 | return TradingRobot(token=self.token, account_id=self.account_id, sandbox_mode=sandbox_mode, 313 | trade_strategy=trade_strategy, trade_statistics=stats, instrument_info=self.instrument_info, 314 | logger=self.logger.getChild(trade_strategy.strategy_id)) 315 | 316 | def _get_current_postitions(self) -> tuple[Money, int]: 317 | # amount of money and instrument balance 318 | with Client(self.token, app_name=self.APP_NAME) as client: 319 | positions = client.operations.get_positions(account_id=self.account_id) 320 | 321 | instruments = [sec for sec in positions.securities if sec.figi == self.instrument_info.figi] 322 | if len(instruments) > 0: 323 | instrument = instruments[0].balance 324 | else: 325 | instrument = 0 326 | 327 | moneys = [m for m in positions.money if m.currency == self.instrument_info.currency] 328 | if len(moneys) > 0: 329 | money = Money(moneys[0].units, moneys[0].nano) 330 | else: 331 | money = Money(0, 0) 332 | 333 | return money, instrument 334 | 335 | @staticmethod 336 | def _validate_account(token: str, account_id: str, logger: logging.Logger) -> bool: 337 | try: 338 | with Client(token, app_name=TradingRobotFactory.APP_NAME) as client: 339 | accounts = [acc for acc in client.users.get_accounts().accounts if acc.id == account_id] 340 | sandbox_mode = False 341 | if len(accounts) == 0: 342 | sandbox_mode = True 343 | accounts = [acc for acc in client.sandbox.get_sandbox_accounts().accounts if acc.id == account_id] 344 | if len(accounts) == 0: 345 | logger.error(f'Account {account_id} not found.') 346 | raise ValueError('Account not found') 347 | 348 | account = accounts[0] 349 | if account.type not in [AccountType.ACCOUNT_TYPE_TINKOFF, AccountType.ACCOUNT_TYPE_INVEST_BOX]: 350 | logger.error(f'Account type {account.type} is not supported') 351 | raise ValueError('Unsupported account type') 352 | if account.status != AccountStatus.ACCOUNT_STATUS_OPEN: 353 | logger.error(f'Account status {account.status} is not supported') 354 | raise ValueError('Unsupported account status') 355 | if account.access_level != AccessLevel.ACCOUNT_ACCESS_LEVEL_FULL_ACCESS: 356 | logger.error(f'No access to account. Current level is {account.access_level}') 357 | raise ValueError('Insufficient access level') 358 | 359 | return sandbox_mode 360 | 361 | except InvestError as error: 362 | logger.error(f'Failed to validate account. Exception: {error}') 363 | raise error 364 | 365 | @staticmethod 366 | def _get_instrument_info(token: str, figi: str = None, ticker: str = None, class_code: str = None): 367 | with Client(token, app_name=TradingRobotFactory.APP_NAME) as client: 368 | if figi is None: 369 | if ticker is None or class_code is None: 370 | raise ValueError('figi or both ticker and class_code must be not None') 371 | return client.instruments.get_instrument_by(id_type=InstrumentIdType.INSTRUMENT_ID_TYPE_TICKER, 372 | class_code=class_code, id=ticker) 373 | return client.instruments.get_instrument_by(id_type=InstrumentIdType.INSTRUMENT_ID_TYPE_FIGI, id=figi) 374 | --------------------------------------------------------------------------------