Тинькофф инвестиции пульс 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]: https://github.com/meanother/tpulse-py/actions/workflows/build.yml/badge.svg
[build-url]: https://github.com/meanother/tpulse-py/actions/workflows/build.yml
[coverage-image]: https://codecov.io/gh/meanother/tpulse-py/branch/main/graph/badge.svg
[coverage-url]: https://codecov.io/gh/nameanotherlgeon/tpulse-py
[quality-image]: https://api.codeclimate.com/v1/badges/ca8f259b0ad93f1f28ed/maintainability
[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.