Тинькофф инвестиции пульс api wrapper

Введение
В этом посте я хотел бы уделить внимание созданию полноценного python-пакета на основе wrapper’a (обертки) над API социальной сети Пульс в Тинькофф инвестициях
Пакет будет включаться в себя 3 базовых метода:
- Получить базовую информацию по пользователю
- Получить список постов по id пользователя
- Получить список постов по тикеру
Результат
Итоговый python пакет: https://pypi.
org/project/tpulse/
Репозиторий в github: https://github.com/meanother/tpulse-py
pip install tpulse
Разработка пакета
Нам понадобятся 2 python-модуля:
- httpx (аналог requests)
- fake_useragent (подмена user-agent в заголовках запроса)
pip install httpx fake_useragent
class ClientBase:
class ClientBase: """Base class for API client""" def __init__(self, base_url: str): headers = { "Content-type": "application/json", "Accept": "application/json", "User-agent": ua, } data = {"appName": "invest", "origin": "web", "platform": "web"} self._client = httpx.Client(base_url=base_url, headers=headers, params=data) def __enter__(self) -> "ClientBase": return self def __exit__(self, exc_type, exc_value, traceback): self.close() def close(self): """Close network connections""" self._client.close() def _get(self, url, data, timeout=settings.TIMEOUT_SEC): """GET request to Dadata API""" response = self._client.get(url, params=data, timeout=timeout) response.raise_for_status() return response.json()
class UserClient:
class UserClient(ClientBase): """User class for tpulse api""" BASE_URL = "https://www.
tinkoff.ru/api/invest-gw/social/v1/" def __init__(self): super().__init__(base_url=self.BASE_URL) def user_info(self, name: str) -> Optional[Dict]: """get user info by username""" url = "profile/nickname/%s" % name response = self._get(url, data=None) return response["payload"] if response["status"] == "Ok" else None def user_posts(self, user_id: str, cursor: int, **kwargs) -> Optional[Dict]: """get user posts by user id""" url = "profile/%s/post" % user_id data = {"limit": 30, "cursor": cursor} data.update(kwargs) response = self._get(url, data) return response["payload"] if response["status"] == "Ok" else None
class PostClient:
class PostClient(ClientBase): """Ticker class for tpulse api""" BASE_URL = "https://www.tinkoff.ru/api/invest-gw/social/v1/" def __init__(self): super().__init__(base_url=self.BASE_URL) def posts(self, ticker: str, cursor: int, **kwargs) -> Optional[Dict]: """get post info by ticker""" url = "post/instrument/%s" % ticker data = {"limit": 30, "cursor": cursor} data.update(kwargs) response = self._get(url, data) return response["payload"] if response["status"] == "Ok" else None
class PulseClient:
class PulseClient: """Sync client for tpulse api""" def __init__(self): self._user = UserClient() self._post = PostClient() def test(self): """test func""" pass def get_user_info(self, name: str) -> Optional[Dict]: """Get user info""" return self._user.user_info(name) def get_posts_by_user_id( self, user_id: str, cursor: int = 999999999, **kwargs ) -> Optional[Dict]: """Collect last 30 posts for user""" return self._user.user_posts(user_id, cursor, **kwargs) def get_posts_by_ticker( self, ticker: str, cursor: int = 999999999, **kwargs ) -> Optional[Dict]: """Collect last 30 posts for ticker""" return self._post.posts(ticker, cursor, **kwargs) def __enter__(self) -> "PulseClient": return self def __exit__(self, exc_type, exc_value, traceback): self.close() def close(self): """Close network connections""" self._user.close() self._post.close()
Инициализация пакета
Для начала создадим шаблон пакета с помощью утилиты flit
Установим ее:
pip install flit
В интерактивном режиме заполним базовое описание пакета
flit init
По итогу получаем следующий файл: pyproject.toml
Flit создал файл с метаданными проекта pyproject.toml
. В нем уже есть все необходимое для публикации пакета в публичном репозитории — PyPi.
Далее необходимо зарегистрироваться в тестовом и основном PyPi репозиториях:
- тестовый https://test.pypi.org/
- основной https://pypi.org/
Для дальнейшего использования потребуется создать файл в корне диска ~/.pypirc
[distutils] index-servers = pypi testpypi [testpypi] repository = https://test.pypi.org/legacy/ username: artydev password: your_password [pypi] username: artydev password: your_password
Публикация пакета
# В тестомовм репозитории flit publish --repository pypitest # В основном репозитории flit publish
Качество кода и автотесты
Установим необходимые пакеты и создадим конфигурационный файл tox.ini
pip install black coverage flake8 mccabe mypy pylint pytest tox
[gh-actions] python = 3.7: py37 3.8: py38 3.9: py39 [tox] isolated_build = True envlist = python3.7,py38,py39 [testenv] deps = black coverage flake8 mccabe pylint pytest httpx fake_useragent pytest_httpx commands = black tpulse flake8 tpulse pylint tpulse coverage erase coverage run --include=tpulse/* -m pytest -ra coverage report -m coverage xml
Запуск проверок осуществляется с помощью команды tox -e py38
для конкретной версии, либо tox
для полной проверки
py38 run-test: commands[5] | coverage report -m Name Stmts Miss Cover Missing ----------------------------------------------------- tpulse/__init__.py 2 0 100% tpulse/settings.py 1 0 100% tpulse/sync_client.py 67 6 91% 25, 28, 32, 42-44 ----------------------------------------------------- TOTAL 70 6 91% py38 run-test: commands[6] | coverage xml Wrote XML report to coverage.xml py38: commands succeeded congratulations :)
Сборка в облаке
GitHub Actions позволяет запускать сборки и автотесты в облаке, используя docker
-контейнеры
- Покрытие кода через Codecov
- Качество кода через Codeclimate
Добавим конфиг для GitHub Actions в .github/workflows/build.yml
:
name: build on: push: branches: [main] pull_request: branches: [main] workflow_dispatch: jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: [3.7, 3.8, 3.9] env: USING_COVERAGE: "3.9" steps: - name: Checkout sources uses: actions/checkout@v2 - name: Set up Python uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install black coverage flake8 flit mccabe mypy pylint pytest tox tox-gh-actions httpx fake_useragent pytest_httpx - name: Run tox run: | python -m tox - name: Upload coverage to Codecov uses: codecov/codecov-action@v1 if: contains(env.USING_COVERAGE, matrix.python-version) with: fail_ci_if_error: true
Для отображения бирок добавим следующие строки в README.md
[![PyPI Version][pypi-image]][pypi-url] [![Build Status][build-image]][build-url] [![Code Coverage][coverage-image]][coverage-url] [![Code Quality][quality-image]][quality-url] [pypi-image]: https://img.shields.io/pypi/v/tpulse [pypi-url]: https://pypi.org/project/tpulse/ [build-image]: [build-url]: https://github.com/meanother/tpulse-py/actions/workflows/bui... [coverage-image]: [coverage-url]: https://codecov.io/gh/nameanotherlgeon/tpulse-py [quality-image]: https://api.codeclimate.com/v1/badges/ca8f259b0ad93f1f28ed/m... [quality-url]: https://codeclimate.com/github/meanother/tpulse-py
Установка и использование
Наш модуль опубликован в pip репозитории, откуда мы можем его смело установить:
pip install tpulse
Базовая информация по пользователю
>>> from tpulse import TinkoffPulse >>> from pprint import pp >>> pulse = TinkoffPulse() >>> >>> user_info = pulse.get_user_info("tomcapital") >>> pp(user_info) {'id': 'bfbc4cc2-7f98-472e-8f5f-a14bdd6fc4db', 'type': 'personal', 'nickname': 'TomCapital', 'status': 'open', 'image': '22ac448f-e271-463c-beb1-f035c7987f17', 'block': False, 'description': 'Эксклюзивная аналитика тут: https://t.me/tomcapital\n' '\n' 'Связь: https://t.me/TomCapCat\n' '\n' 'growth stocks strategy\n' '\n' 'Ты должен изучить правила игры. Затем начать играть лучше, ' 'чем кто-либо другой.', 'followersCount': 39704, 'followingCount': 13, 'isLead': False, 'serviceTags': [{'id': 'popular'}], 'statistics': {'totalAmountRange': {'lower': 3000000, 'upper': None}, 'yearRelativeYield': -5.68, 'monthOperationsCount': 98}, 'subscriptionDomains': None, 'popularHashtags': [], 'donationActive': True, 'isVisible': True, 'baseTariffCategory': 'unauthorized', 'strategies': [{'id': 'a48ee1fc-4eaa-47a3-a75c-a362d3c95cdf', 'title': 'Tactical Investing', 'riskProfile': 'moderate', 'relativeYield': 3.93, 'baseCurrency': 'usd', 'score': 4, 'portfolioValues': [...], 'characteristics': [{'id': 'recommended-base-money-position-quantity', 'value': '1\xa0100 $', 'subtitle': 'советуем вложить'}, {'id': 'slaves-count', 'value': '111', 'subtitle': 'подписаны'}]}, {'id': 'ff41c693-78dd-4c2e-b566-858770d6d2e0', 'title': 'Aggressive investing', 'riskProfile': 'aggressive', 'relativeYield': -8.19, 'baseCurrency': 'usd', 'score': 3, 'portfolioValues': [...], 'characteristics': [{'id': 'recommended-base-money-position-quantity', 'value': '1\xa0000 $', 'subtitle': 'советуем вложить'}, {'id': 'slaves-count', 'value': '17', 'subtitle': 'подписаны'}]}]}
Список постов по id пользователя
>>> user_posts = pulse.get_posts_by_user_id("bfbc4cc2-7f98-472e-8f5f-a14bdd6fc4db") >>> pp(user_posts) ... >>> pp(user_posts["items"][0]) {'id': '2ab5457c-aa9d-4a9b-b7ea-7af49459f0f9', 'text': 'Множество акций испытали массивную коррекцию за последние несколько ' 'недель, особенно это касается growth-историй (компаний, чей ' 'потенциал и денежные потоки должны раскрыться в будущем). На ' 'фондовый рынок обрушилась целая лавина плохих новостей (высказывания ' 'Пауэлла, тейперинг, Omicron и тд), и, на мой взгляд, мы увидели ' 'некую чрезмерную реакцию рынка.\n' '\n' 'Часто, когда фондовый рынок заранее корректируется и закладывает те ' 'или иные негативные события в оценку активов, то уже непосредственно ' 'по факту наступления этих самых событий, рынок, как правило, ' 'успевает переварить их, и, наоборот, раллирует. Особенно, если ' 'случилась избыточная или даже паническая реакция на негатив.\n' '\n' 'Марко Коланович, главный стратег JPMorgan, оценивает вероятность ' 'шорт-сквиз ралли в ближайшие недели, как высокую, и я, пожалуй, буду ' 'придерживаться такой же точки зрения.', 'likesCount': 42, 'commentsCount': 10, 'isLiked': False, 'inserted': '2021-12-22T15:22:38.016128+03:00', 'isEditable': False, 'instruments': [], 'profiles': [], 'serviceTags': [], 'profileId': 'bfbc4cc2-7f98-472e-8f5f-a14bdd6fc4db', 'nickname': 'TomCapital', 'image': '22ac448f-e271-463c-beb1-f035c7987f17', 'postImages': [], 'hashtags': [], 'owner': {'id': 'bfbc4cc2-7f98-472e-8f5f-a14bdd6fc4db', 'nickname': 'TomCapital', 'image': '22ac448f-e271-463c-beb1-f035c7987f17', 'donationActive': False, 'block': False, 'serviceTags': [{'id': 'popular'}]}, 'reactions': {'totalCount': 42, 'myReaction': None, 'counters': [{'type': 'like', 'count': 42}]}, 'content': {'type': 'simple', 'text': '', 'instruments': [], 'hashtags': [], 'profiles': [], 'images': [], 'strategies': []}, 'baseTariffCategory': 'unauthorized', 'isBookmarked': False, 'status': 'published'}
Список постов по тикеру
>>> ticker_posts = pulse.get_posts_by_ticker("AAPL") >>> pp(ticker_posts) ... >>> pp(ticker_posts["items"][5]) {'id': '320b8e15-fe8c-46e9-b29b-12ef278be135', 'text': '{$AAPL} продажу поставил на 176 $', 'likesCount': 0, 'commentsCount': 6, 'isLiked': False, 'inserted': '2021-12-23T11:54:50.603445+03:00', 'isEditable': False, 'instruments': [{'type': 'share', 'ticker': 'AAPL', 'lastPrice': 176.02, 'currency': 'usd', 'image': 'US0378331005.png', 'briefName': 'Apple', 'dailyYield': None, 'relativeDailyYield': 0.0, 'price': 175.34, 'relativeYield': 0.39}], 'profiles': [], 'serviceTags': [], 'profileId': '436a1012-3c5d-4c84-879b-a4e434f43230', 'nickname': 'TNEO', 'image': 'fc85fbc9-ef4a-4045-905d-bd6fb581689c', 'postImages': [], 'hashtags': [], 'owner': {'id': '436a1012-3c5d-4c84-879b-a4e434f43230', 'nickname': 'TNEO', 'image': 'fc85fbc9-ef4a-4045-905d-bd6fb581689c', 'donationActive': False, 'block': False, 'serviceTags': []}, 'reactions': {'totalCount': 0, 'myReaction': None, 'counters': []}, 'content': {'type': 'simple', 'text': '', 'instruments': [{'type': 'share', 'ticker': 'AAPL', 'lastPrice': 176.02, 'currency': 'usd', 'image': 'US0378331005.png', 'briefName': 'Apple', 'dailyYield': None, 'relativeDailyYield': 0.0, 'price': 175.34, 'relativeYield': 0.39}], 'hashtags': [], 'profiles': [], 'images': [], 'strategies': []}, 'baseTariffCategory': 'unauthorized', 'isBookmarked': False, 'status': 'published'}
Если вам интересны подобные рассуждения, подписывайтесь на мой канал artydev & Co.