├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── pyproject.toml ├── requirements-dev.txt ├── setup.cfg ├── setup.py └── tinkoff ├── __init__.py ├── base.py ├── investments ├── __init__.py ├── api │ ├── __init__.py │ ├── base.py │ ├── market.py │ ├── operations.py │ ├── orders.py │ ├── portfolio.py │ ├── sandbox.py │ └── user.py ├── client │ ├── __init__.py │ ├── environments.py │ ├── exceptions.py │ ├── rest.py │ ├── streaming.py │ └── streaming.pyi ├── model │ ├── __init__.py │ ├── base.py │ ├── market │ │ ├── __init__.py │ │ ├── candles.py │ │ ├── instruments.py │ │ └── orderbook.py │ ├── operations.py │ ├── orders.py │ ├── portfolio.py │ ├── response.py │ ├── sandbox.py │ ├── streaming.py │ └── user │ │ ├── __init__.py │ │ └── accounts.py └── utils │ ├── __init__.py │ └── historical_data.py └── py.typed /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | indent_style = space 11 | indent_size = 4 12 | trim_trailing_whitespace = true 13 | charset = utf-8 14 | max_line_length=79 15 | 16 | [Makefile] 17 | indent_style = tab 18 | 19 | [*.{yml,yaml,feature,json,toml}] 20 | indent_size = 2 21 | 22 | [*.{tsv,csv}] 23 | trim_trailing_whitespace = false 24 | 25 | [*.rst] 26 | max_line_length = 80 27 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: ['https://coindrop.to/tikhonov_a'] 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | python-version: [3.7, 3.8] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Set up Python ${{ matrix.python-version }} 21 | uses: actions/setup-python@v1 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | - name: Install dependencies 25 | run: make devel 26 | - name: Test with mypy 27 | run: make mypy 28 | - name: Link with flake8 29 | run: make flake 30 | - name: Run black 31 | run: black --check . 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | 59 | # PyCharm 60 | .idea 61 | 62 | # pyenv 63 | .python-version 64 | 65 | # OSX 66 | .DS_Store 67 | 68 | # mypy 69 | .mypy_cache 70 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: devel test mypy 2 | 3 | all: devel test 4 | devel: 5 | pip install --upgrade pip 6 | pip install -e . 7 | pip install -r requirements-dev.txt 8 | test: mypy flake 9 | mypy: 10 | mypy tinkoff 11 | flake: 12 | flake8 tinkoff 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tinkoff-api 2 | 3 | > Python Tinkoff API client for asyncio and humans. 4 | 5 | [![build](https://github.com/Fatal1ty/tinkoff-api/workflows/build/badge.svg)](https://github.com/Fatal1ty/tinkoff-api/actions?query=workflow%3Abuild) 6 | [![Latest Version](https://img.shields.io/pypi/v/tinkoff-api.svg)](https://pypi.python.org/pypi/tinkoff-api) 7 | [![Python Version](https://img.shields.io/pypi/pyversions/tinkoff-api.svg)](https://pypi.python.org/pypi/tinkoff-api) 8 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 9 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 10 | 11 | 12 | Table of contens 13 | -------------------------------------------------------------------------------- 14 | * [Covered APIs](#covered-apis) 15 | * [Features](#features) 16 | * [Installation](#installation) 17 | * [Usage examples](#usage-examples) 18 | * [REST API client](#rest-api-client) 19 | * [Streaming client](#streaming-client) 20 | * [Dynamic subscriptions in runtime](#dynamic-subscriptions-in-runtime) 21 | * [Complete simple bot](#complete-simple-bot) 22 | * [Historical data](#historical-data) 23 | * [TODO](#todo) 24 | 25 | 26 | Covered APIs 27 | -------------------------------------------------------------------------------- 28 | * Tinkoff Investments ([official docs](https://tinkoffcreditsystems.github.io/invest-openapi/)) 29 | 30 | 31 | Features 32 | -------------------------------------------------------------------------------- 33 | * Clients for both [REST](https://tinkoffcreditsystems.github.io/invest-openapi/rest/) and [Streaming](https://tinkoffcreditsystems.github.io/invest-openapi/marketdata/) protocols in Tinkoff Investments 34 | * Presence of data classes for all interaction with API 35 | * Automatic reconnection and keep-alive connections 36 | * Internal exclusive rate limiter for every resource in REST protocol 37 | * Friendly exceptions for API errors 38 | 39 | 40 | Installation 41 | -------------------------------------------------------------------------------- 42 | 43 | Use pip to install: 44 | ```shell 45 | $ pip install tinkoff-api 46 | ``` 47 | 48 | Usage examples 49 | -------------------------------------------------------------------------------- 50 | 51 | #### REST API client: 52 | ```python 53 | import asyncio 54 | from datetime import datetime 55 | from tinkoff.investments import ( 56 | TinkoffInvestmentsRESTClient, 57 | Environment, 58 | CandleResolution, 59 | ) 60 | from tinkoff.investments.client.exceptions import TinkoffInvestmentsError 61 | 62 | 63 | async def show_apple_year_candles(): 64 | try: 65 | async with TinkoffInvestmentsRESTClient( 66 | token="TOKEN", environment=Environment.SANDBOX 67 | ) as client: 68 | 69 | candles = await client.market.candles.get( 70 | figi="BBG000B9XRY4", 71 | dt_from=datetime(2019, 1, 1), 72 | dt_to=datetime(2019, 12, 31), 73 | interval=CandleResolution.DAY, 74 | ) 75 | for candle in candles: 76 | print(f"{candle.time}: {candle.h}") 77 | except TinkoffInvestmentsError as e: 78 | print(e) 79 | 80 | 81 | async def jackpot(): 82 | try: 83 | async with TinkoffInvestmentsRESTClient( 84 | token="TOKEN", environment=Environment.SANDBOX 85 | ) as client: 86 | 87 | instruments = await client.market.instruments.search("AAPL") 88 | apple = instruments[0] 89 | 90 | account = await client.sandbox.accounts.register() 91 | await client.sandbox.accounts.positions.set_balance( 92 | figi=apple.figi, 93 | balance=100, 94 | broker_account_id=account.brokerAccountId, 95 | ) 96 | 97 | print("We created the following portfolio:") 98 | positions = await client.portfolio.get_positions() 99 | for position in positions: 100 | print(f"{position.name}: {position.lots} lots") 101 | except TinkoffInvestmentsError as e: 102 | print(e) 103 | 104 | 105 | asyncio.run(jackpot()) 106 | ``` 107 | 108 | #### Streaming Client: 109 | ```python 110 | import asyncio 111 | from datetime import datetime 112 | from tinkoff.investments import ( 113 | CandleEvent, 114 | CandleResolution, 115 | TinkoffInvestmentsStreamingClient, 116 | ) 117 | 118 | client = TinkoffInvestmentsStreamingClient(token="TOKEN") 119 | 120 | 121 | @client.events.candles("BBG009S39JX6", CandleResolution.MIN_1) 122 | @client.events.candles("BBG000B9XRY4", CandleResolution.MIN_1) 123 | async def on_candle(candle: CandleEvent, server_time: datetime): 124 | print(candle, server_time) 125 | 126 | 127 | asyncio.run(client.run()) 128 | ``` 129 | 130 | #### Dynamic subscriptions in runtime: 131 | ```python 132 | import asyncio 133 | from datetime import datetime 134 | from tinkoff.investments import ( 135 | CandleEvent, 136 | CandleResolution, 137 | TinkoffInvestmentsStreamingClient, 138 | ) 139 | 140 | client = TinkoffInvestmentsStreamingClient(token="TOKEN") 141 | 142 | 143 | @client.events.candles("BBG000B9XRY4", CandleResolution.HOUR) 144 | async def on_candle(candle: CandleEvent, server_time: datetime): 145 | if candle.h > 1000: 146 | await client.events.candles.subscribe( 147 | callback=on_candle, 148 | figi=candle.figi, 149 | interval=CandleResolution.MIN_1, 150 | ) 151 | elif candle.h < 1000: 152 | await client.events.candles.unsubscribe( 153 | candle.figi, CandleResolution.MIN_1 154 | ) 155 | 156 | 157 | asyncio.run(client.run()) 158 | ``` 159 | 160 | #### Complete simple bot: 161 | ```python 162 | import asyncio 163 | from datetime import datetime 164 | 165 | from tinkoff.investments import ( 166 | CandleEvent, 167 | CandleResolution, 168 | OperationType, 169 | TinkoffInvestmentsRESTClient, 170 | TinkoffInvestmentsStreamingClient, 171 | ) 172 | 173 | streaming = TinkoffInvestmentsStreamingClient("TOKEN") 174 | rest = TinkoffInvestmentsRESTClient("TOKEN") 175 | 176 | 177 | @streaming.events.candles("BBG000B9XRY4", CandleResolution.MIN_1) 178 | async def buy_apple(candle: CandleEvent, server_time: datetime): 179 | if candle.c > 350: 180 | await rest.orders.create_market_order( 181 | figi="BBG000B9XRY4", 182 | lots=1, 183 | operation=OperationType.BUY, 184 | broker_account_id=123, 185 | ) 186 | 187 | 188 | asyncio.run(streaming.run()) 189 | ``` 190 | 191 | #### Historical data: 192 | ```python 193 | import asyncio 194 | from datetime import datetime 195 | 196 | from tinkoff.investments import ( 197 | CandleResolution, 198 | Environment, 199 | TinkoffInvestmentsRESTClient, 200 | ) 201 | from tinkoff.investments.utils.historical_data import HistoricalData 202 | 203 | 204 | async def get_minute_candles(): 205 | # show 1 minute candles for AAPL in 1 year period of time 206 | async with TinkoffInvestmentsRESTClient( 207 | token="TOKEN", environment=Environment.SANDBOX 208 | ) as client: 209 | historical_data = HistoricalData(client) 210 | async for candle in historical_data.iter_candles( 211 | figi="BBG000B9XRY4", 212 | dt_from=datetime(2019, 1, 1), 213 | dt_to=datetime(2020, 1, 1), 214 | interval=CandleResolution.MIN_1, 215 | ): 216 | print(candle) 217 | 218 | 219 | asyncio.run(get_minute_candles()) 220 | ``` 221 | 222 | TODO 223 | -------------------------------------------------------------------------------- 224 | 225 | * allow to provide str constants along with specific enum objects 226 | * add ability to unsubscribe by pattern 227 | * rename some fields 228 | * make some fields in snake case 229 | * generate documentation 230 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = true 3 | 4 | [flake8] 5 | max-line-length = 79 6 | 7 | [tool.isort] 8 | multi_line_output = 3 9 | include_trailing_comma = true 10 | ensure_newline_before_comments = true 11 | 12 | [tool.black] 13 | line-length = 79 14 | target-version = ['py36', 'py37', 'py38'] 15 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | mypy>=0.790 2 | flake8>=3.8.4 3 | flake8-isort>=4.0.0 4 | black>=20.8b1 5 | 6 | # library-stubs 7 | types-dataclasses 8 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = True 3 | 4 | [flake8] 5 | max-line-length = 80 6 | per-file-ignores = 7 | tinkoff/investments/__init__.py:F401 8 | tinkoff/investments/model/streaming.py:E741 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from setuptools import find_packages, setup 4 | 5 | setup( 6 | name="tinkoff-api", 7 | version="1.19", 8 | description="Python Tinkoff API client for asyncio and humans", 9 | long_description=open("README.md", encoding="utf8").read(), 10 | long_description_content_type="text/markdown", 11 | platforms="all", 12 | classifiers=[ 13 | "License :: OSI Approved :: Apache Software License", 14 | "Intended Audience :: Developers", 15 | "Topic :: Office/Business :: Financial", 16 | "Topic :: Office/Business :: Financial :: Investment", 17 | "Programming Language :: Python :: 3 :: Only", 18 | "Programming Language :: Python :: 3.6", 19 | "Programming Language :: Python :: 3.7", 20 | "Programming Language :: Python :: 3.8", 21 | "Development Status :: 4 - Beta", 22 | ], 23 | license="Apache License, Version 2.0", 24 | author="Alexander Tikhonov", 25 | author_email="random.gauss@gmail.com", 26 | url="https://github.com/Fatal1ty/tinkoff-api", 27 | package_data={ 28 | "tinkoff": [ 29 | "py.typed", 30 | "investments/client/streaming.pyi", 31 | "investments/model/proto/*.pyi", 32 | ], 33 | }, 34 | packages=find_packages(exclude=("tests",)), 35 | python_requires=">=3.6", 36 | install_requires=[ 37 | "aiohttp>=3.6.2,<4", 38 | "yarl>=1.4.2", 39 | "mashumaro>=3.0,<4", 40 | "ciso8601==2.1.2", 41 | ], 42 | ) 43 | -------------------------------------------------------------------------------- /tinkoff/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fatal1ty/tinkoff-api/64764b9c6820e78300ff8816276700959ded9177/tinkoff/__init__.py -------------------------------------------------------------------------------- /tinkoff/base.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | from collections import deque 4 | from typing import Deque, Dict, Optional 5 | 6 | import aiohttp 7 | from yarl import URL 8 | 9 | 10 | class BaseHTTPClient: 11 | def __init__(self, base_url, headers=None, timeout=None): 12 | # type: (URL, Optional[Dict[str, str]], Optional[float]) -> None 13 | self._base_url = base_url 14 | self.__headers = headers 15 | self.__timeout = timeout 16 | self.__session = None # type: Optional[aiohttp.ClientSession] 17 | 18 | self._closed = False 19 | 20 | @property 21 | def _session(self): 22 | if not self.__session: 23 | self.__session = aiohttp.ClientSession( 24 | headers=self.__headers, 25 | timeout=aiohttp.ClientTimeout(total=self.__timeout), 26 | ) 27 | return self.__session 28 | 29 | async def close(self): 30 | self._closed = True 31 | await self._session.close() 32 | self.__session = None 33 | 34 | @property 35 | def closed(self): 36 | return self._closed 37 | 38 | 39 | # noinspection PyPep8Naming 40 | class classproperty(object): 41 | def __init__(self, f): 42 | self.f = f 43 | 44 | def __get__(self, obj, owner): 45 | return self.f(owner) 46 | 47 | 48 | class RateLimitReached(Exception): 49 | pass 50 | 51 | 52 | class RateLimiter: 53 | def __init__(self, rate: int, period: float): 54 | self.rate = rate 55 | self.period = period 56 | self.request_times: Deque[float] = deque() 57 | 58 | async def __aenter__(self): 59 | await self.acquire() 60 | 61 | async def __aexit__(self, exc_type, exc_val, exc_tb): 62 | pass 63 | 64 | async def acquire(self, blocking=True): 65 | while not self._try_to_acquire(): 66 | if not blocking: 67 | raise RateLimitReached 68 | await asyncio.sleep(0.25) 69 | 70 | def _try_to_acquire(self): 71 | now = time.monotonic() 72 | while self.request_times: 73 | if now - self.request_times[0] > self.period: 74 | self.request_times.popleft() 75 | else: 76 | break 77 | if len(self.request_times) < self.rate: 78 | self.request_times.append(now) 79 | return True 80 | else: 81 | return False 82 | 83 | 84 | __all__ = [ 85 | "BaseHTTPClient", 86 | "classproperty", 87 | "RateLimiter", 88 | "RateLimitReached", 89 | ] 90 | -------------------------------------------------------------------------------- /tinkoff/investments/__init__.py: -------------------------------------------------------------------------------- 1 | from .client.environments import Environment 2 | from .client.rest import TinkoffInvestmentsRESTClient 3 | from .client.streaming import EventsBroker, TinkoffInvestmentsStreamingClient 4 | from .model.base import ( 5 | Currency, 6 | FigiName, 7 | InstrumentType, 8 | MoneyAmount, 9 | TickerName, 10 | ) 11 | from .model.market.candles import Candle, CandleResolution 12 | from .model.market.instruments import MarketInstrument 13 | from .model.market.orderbook import OrderBook, OrderBookEntity, TradingStatus 14 | from .model.operations import ( 15 | Operation, 16 | OperationID, 17 | OperationStatus, 18 | OperationTrade, 19 | OperationType, 20 | OperationTypeWithCommission, 21 | TradeID, 22 | ) 23 | from .model.orders import ( 24 | Order, 25 | OrderID, 26 | OrderStatus, 27 | OrderType, 28 | PlacedLimitOrder, 29 | PlacedMarketOrder, 30 | ) 31 | from .model.portfolio import CurrencyPosition, PortfolioPosition 32 | from .model.sandbox import SandboxAccount 33 | from .model.streaming import ( 34 | CandleEvent, 35 | ErrorEvent, 36 | InstrumentInfoEvent, 37 | OrderBookEvent, 38 | ) 39 | from .model.user.accounts import ( 40 | BrokerAccountID, 41 | BrokerAccountType, 42 | UserAccount, 43 | ) 44 | -------------------------------------------------------------------------------- /tinkoff/investments/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .market import MarketAPI 2 | from .operations import OperationsAPI 3 | from .orders import OrdersAPI 4 | from .portfolio import PortfolioAPI 5 | from .sandbox import SandboxAPI 6 | from .user import UserAPI 7 | 8 | __all__ = [ 9 | "SandboxAPI", 10 | "OrdersAPI", 11 | "PortfolioAPI", 12 | "MarketAPI", 13 | "OperationsAPI", 14 | "UserAPI", 15 | ] 16 | -------------------------------------------------------------------------------- /tinkoff/investments/api/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Union 2 | 3 | from tinkoff.base import RateLimiter 4 | from tinkoff.investments.client.exceptions import TinkoffInvestmentsAPIError 5 | from tinkoff.investments.model.base import Error 6 | from tinkoff.investments.model.response import TinkoffInvestmentsAPIResponse 7 | 8 | 9 | class BaseTinkoffInvestmentsAPI: 10 | def __init__(self, client): 11 | self._client = client 12 | self._default_rate_limit = self._get_default_rate_limiter() 13 | self._path_rate_limits = self._get_path_rate_limiters() 14 | 15 | def _get_default_rate_limiter(self) -> RateLimiter: 16 | return RateLimiter(rate=120, period=60) 17 | 18 | def _get_path_rate_limiters(self) -> Dict[str, RateLimiter]: 19 | return {} 20 | 21 | async def _request(self, method, path, **kwargs): 22 | # type: (str, str, Any) -> Union[Dict[Any, Any], List[Any]] 23 | rate_limit = self._path_rate_limits.get(path, self._default_rate_limit) 24 | # noinspection PyProtectedMember 25 | data = await self._client._request(method, path, rate_limit, **kwargs) 26 | response = TinkoffInvestmentsAPIResponse.from_dict(data) 27 | if response.is_successful(): 28 | return response.payload 29 | else: 30 | raise TinkoffInvestmentsAPIError( 31 | tracking_id=response.trackingId, 32 | status=response.status, 33 | error=Error( 34 | message=response.payload.get("message"), 35 | code=response.payload.get("code"), 36 | ), 37 | ) 38 | 39 | 40 | __all__ = [ 41 | "BaseTinkoffInvestmentsAPI", 42 | ] 43 | -------------------------------------------------------------------------------- /tinkoff/investments/api/market.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any, List, Optional 3 | 4 | from tinkoff.base import RateLimiter 5 | from tinkoff.investments.api.base import BaseTinkoffInvestmentsAPI 6 | from tinkoff.investments.client.exceptions import TinkoffInvestmentsAPIError 7 | from tinkoff.investments.model.base import FigiName, TickerName 8 | from tinkoff.investments.model.market.candles import ( 9 | Candle, 10 | CandleResolution, 11 | Candles, 12 | ) 13 | from tinkoff.investments.model.market.instruments import ( 14 | MarketInstrument, 15 | MarketInstrumentList, 16 | ) 17 | from tinkoff.investments.model.market.orderbook import OrderBook 18 | from tinkoff.investments.utils import offset_aware_datetime 19 | 20 | 21 | class MarketInstrumentsAPI(BaseTinkoffInvestmentsAPI): 22 | def _get_default_rate_limiter(self) -> RateLimiter: 23 | return RateLimiter(rate=240, period=60) 24 | 25 | async def search(self, ticker: TickerName) -> List[MarketInstrument]: 26 | return await self.__get_instruments( 27 | path="/market/search/by-ticker", 28 | ticker=ticker, 29 | ) 30 | 31 | async def get(self, figi: FigiName) -> Optional[MarketInstrument]: 32 | try: 33 | payload = await self._request( 34 | method="GET", 35 | path="/market/search/by-figi", 36 | params={"figi": figi}, 37 | ) 38 | return MarketInstrument.from_dict(payload) # type: ignore 39 | except TinkoffInvestmentsAPIError as e: 40 | if e.error.code == "NOT_FOUND": 41 | return None 42 | else: 43 | raise e from None 44 | 45 | async def get_stocks(self) -> List[MarketInstrument]: 46 | return await self.__get_instruments("/market/stocks") 47 | 48 | async def get_bonds(self) -> List[MarketInstrument]: 49 | return await self.__get_instruments("/market/bonds") 50 | 51 | async def get_etfs(self) -> List[MarketInstrument]: 52 | return await self.__get_instruments("/market/etfs") 53 | 54 | async def get_currencies(self) -> List[MarketInstrument]: 55 | return await self.__get_instruments("/market/currencies") 56 | 57 | async def __get_instruments(self, path, **params): 58 | # type: (str, Any) -> List[MarketInstrument] 59 | payload = await self._request( 60 | method="GET", 61 | path=path, 62 | params=params, 63 | ) 64 | return MarketInstrumentList.from_dict( 65 | payload # type: ignore 66 | ).instruments 67 | 68 | 69 | class MarketOrderBooksAPI(BaseTinkoffInvestmentsAPI): 70 | async def get(self, figi: FigiName, depth: int) -> OrderBook: 71 | payload = await self._request( 72 | method="GET", 73 | path="/market/orderbook", 74 | params={ 75 | "figi": figi, 76 | "depth": depth, 77 | }, 78 | ) 79 | return OrderBook.from_dict(payload) # type: ignore 80 | 81 | 82 | class MarketCandlesAPI(BaseTinkoffInvestmentsAPI): 83 | async def get(self, figi, dt_from, dt_to, interval): 84 | # type: (FigiName, datetime, datetime, CandleResolution) -> List[Candle] 85 | dt_from = offset_aware_datetime(dt_from) 86 | dt_to = offset_aware_datetime(dt_to) 87 | payload = await self._request( 88 | method="GET", 89 | path="/market/candles", 90 | params={ 91 | "figi": figi, 92 | "from": dt_from.isoformat(), 93 | "to": dt_to.isoformat(), 94 | "interval": interval.value, 95 | }, 96 | ) 97 | return Candles.from_dict(payload).candles # type: ignore 98 | 99 | 100 | class MarketAPI(BaseTinkoffInvestmentsAPI): 101 | def __init__(self, *args, **kwargs): 102 | super(MarketAPI, self).__init__(*args, **kwargs) 103 | self.instruments = MarketInstrumentsAPI(*args, **kwargs) 104 | self.orderbooks = MarketOrderBooksAPI(*args, **kwargs) 105 | self.candles = MarketCandlesAPI(*args, **kwargs) 106 | -------------------------------------------------------------------------------- /tinkoff/investments/api/operations.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import List, Optional 3 | 4 | from tinkoff.investments.api.base import BaseTinkoffInvestmentsAPI 5 | from tinkoff.investments.model.base import FigiName 6 | from tinkoff.investments.model.operations import Operation, Operations 7 | from tinkoff.investments.model.user.accounts import BrokerAccountID 8 | from tinkoff.investments.utils import offset_aware_datetime 9 | 10 | 11 | class OperationsAPI(BaseTinkoffInvestmentsAPI): 12 | async def get( 13 | self, 14 | dt_from: datetime, 15 | dt_to: datetime, 16 | figi: Optional[FigiName] = None, 17 | broker_account_id: Optional[BrokerAccountID] = None, 18 | ) -> List[Operation]: 19 | 20 | dt_from = offset_aware_datetime(dt_from) 21 | dt_to = offset_aware_datetime(dt_to) 22 | params = { 23 | "from": dt_from.isoformat(), 24 | "to": dt_to.isoformat(), 25 | } 26 | if figi is not None: 27 | params["figi"] = figi 28 | if broker_account_id is not None: 29 | params["brokerAccountId"] = broker_account_id 30 | 31 | payload = await self._request( 32 | method="GET", 33 | path="/operations", 34 | params=params, 35 | ) 36 | return Operations.from_dict(payload).operations # type: ignore 37 | -------------------------------------------------------------------------------- /tinkoff/investments/api/orders.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Optional 2 | 3 | from tinkoff.base import RateLimiter 4 | from tinkoff.investments.api.base import BaseTinkoffInvestmentsAPI 5 | from tinkoff.investments.model.base import FigiName 6 | from tinkoff.investments.model.operations import OperationType 7 | from tinkoff.investments.model.orders import ( 8 | LimitOrderRequest, 9 | MarketOrderRequest, 10 | Order, 11 | OrderID, 12 | PlacedLimitOrder, 13 | PlacedMarketOrder, 14 | ) 15 | from tinkoff.investments.model.user.accounts import BrokerAccountID 16 | 17 | 18 | class OrdersAPI(BaseTinkoffInvestmentsAPI): 19 | def _get_default_rate_limiter(self) -> RateLimiter: 20 | return RateLimiter(rate=100, period=60) 21 | 22 | def _get_path_rate_limiters(self) -> Dict[str, RateLimiter]: 23 | return { 24 | "/orders": RateLimiter(rate=100, period=60), 25 | "/orders/limit-order": RateLimiter(rate=100, period=60), 26 | "/orders/market-order": RateLimiter(rate=100, period=60), 27 | "/orders/cancel": RateLimiter(rate=50, period=60), 28 | } 29 | 30 | async def get(self, broker_account_id=None): 31 | # type: (Optional[BrokerAccountID]) -> List[Order] 32 | 33 | if broker_account_id is not None: 34 | params = {"brokerAccountId": broker_account_id} 35 | else: 36 | params = {} 37 | 38 | payload = await self._request( 39 | method="GET", path="/orders", params=params 40 | ) 41 | return [Order.from_dict(obj) for obj in payload] 42 | 43 | async def create_limit_order( 44 | self, 45 | figi: FigiName, 46 | lots: int, 47 | operation: OperationType, 48 | price: float, 49 | broker_account_id: Optional[BrokerAccountID] = None, 50 | ) -> PlacedLimitOrder: 51 | 52 | params = {"figi": figi} 53 | if broker_account_id is not None: 54 | params["brokerAccountId"] = broker_account_id 55 | 56 | payload = await self._request( 57 | method="POST", 58 | path="/orders/limit-order", 59 | params=params, 60 | json=LimitOrderRequest( 61 | lots=lots, 62 | operation=operation, 63 | price=price, 64 | ).to_dict(), 65 | ) 66 | return PlacedLimitOrder.from_dict(payload) # type: ignore 67 | 68 | async def create_market_order( 69 | self, 70 | figi: FigiName, 71 | lots: int, 72 | operation: OperationType, 73 | broker_account_id: Optional[BrokerAccountID] = None, 74 | ) -> PlacedMarketOrder: 75 | 76 | params = {"figi": figi} 77 | if broker_account_id is not None: 78 | params["brokerAccountId"] = broker_account_id 79 | 80 | payload = await self._request( 81 | method="POST", 82 | path="/orders/market-order", 83 | params=params, 84 | json=MarketOrderRequest( 85 | lots=lots, 86 | operation=operation, 87 | ).to_dict(), 88 | ) 89 | return PlacedMarketOrder.from_dict(payload) # type: ignore 90 | 91 | async def cancel(self, order_id, broker_account_id=None): 92 | # type: (OrderID, Optional[BrokerAccountID]) -> None 93 | 94 | params = {"orderId": order_id} 95 | if broker_account_id is not None: 96 | params["brokerAccountId"] = broker_account_id 97 | 98 | await self._request( 99 | method="POST", 100 | path="/orders/cancel", 101 | params=params, 102 | ) 103 | -------------------------------------------------------------------------------- /tinkoff/investments/api/portfolio.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from tinkoff.investments.api.base import BaseTinkoffInvestmentsAPI 4 | from tinkoff.investments.model.portfolio import ( 5 | Currencies, 6 | CurrencyPosition, 7 | Portfolio, 8 | PortfolioPosition, 9 | ) 10 | from tinkoff.investments.model.user.accounts import BrokerAccountID 11 | 12 | 13 | class PortfolioAPI(BaseTinkoffInvestmentsAPI): 14 | async def get_positions(self, broker_account_id=None): 15 | # type: (Optional[BrokerAccountID]) -> List[PortfolioPosition] 16 | if broker_account_id is not None: 17 | params = {"brokerAccountId": broker_account_id} 18 | else: 19 | params = {} 20 | payload = await self._request( 21 | method="GET", 22 | path="/portfolio", 23 | params=params, 24 | ) 25 | return Portfolio.from_dict(payload).positions # type: ignore 26 | 27 | async def get_currencies(self, broker_account_id=None): 28 | # type: (Optional[BrokerAccountID]) -> List[CurrencyPosition] 29 | if broker_account_id is not None: 30 | params = {"brokerAccountId": broker_account_id} 31 | else: 32 | params = {} 33 | payload = await self._request( 34 | method="GET", 35 | path="/portfolio/currencies", 36 | params=params, 37 | ) 38 | return Currencies.from_dict(payload).currencies # type: ignore 39 | -------------------------------------------------------------------------------- /tinkoff/investments/api/sandbox.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from tinkoff.investments.api.base import BaseTinkoffInvestmentsAPI 4 | from tinkoff.investments.model.base import Currency, FigiName 5 | from tinkoff.investments.model.sandbox import ( 6 | SandboxAccount, 7 | SandboxAccountRegisterRequest, 8 | SandboxSetCurrencyBalanceRequest, 9 | SandboxSetPositionBalanceRequest, 10 | ) 11 | from tinkoff.investments.model.user.accounts import ( 12 | BrokerAccountID, 13 | BrokerAccountType, 14 | ) 15 | 16 | 17 | class SandboxAccountCurrenciesAPI(BaseTinkoffInvestmentsAPI): 18 | async def set_balance(self, currency, balance, broker_account_id=None): 19 | # type: (Currency, float, Optional[BrokerAccountID]) -> None 20 | if broker_account_id is not None: 21 | params = {"brokerAccountId": broker_account_id} 22 | else: 23 | params = {} 24 | await self._request( 25 | method="POST", 26 | path="/sandbox/currencies/balance", 27 | params=params, 28 | json=SandboxSetCurrencyBalanceRequest( 29 | currency=currency, balance=balance 30 | ).to_dict(), 31 | ) 32 | 33 | 34 | class SandboxAccountPositionsAPI(BaseTinkoffInvestmentsAPI): 35 | async def set_balance(self, figi, balance, broker_account_id=None): 36 | # type: (FigiName, float, Optional[BrokerAccountID]) -> None 37 | if broker_account_id is not None: 38 | params = {"brokerAccountId": broker_account_id} 39 | else: 40 | params = {} 41 | await self._request( 42 | method="POST", 43 | path="/sandbox/positions/balance", 44 | params=params, 45 | json=SandboxSetPositionBalanceRequest( 46 | figi=figi, balance=balance 47 | ).to_dict(), 48 | ) 49 | 50 | 51 | class SandboxAccountsAPI(BaseTinkoffInvestmentsAPI): 52 | def __init__(self, *args, **kwargs): 53 | super(SandboxAccountsAPI, self).__init__(*args, **kwargs) 54 | self.currencies = SandboxAccountCurrenciesAPI(*args, **kwargs) 55 | self.positions = SandboxAccountPositionsAPI(*args, **kwargs) 56 | 57 | async def register(self, broker_account_type=BrokerAccountType.TINKOFF): 58 | # type: (BrokerAccountType) -> SandboxAccount 59 | payload = await self._request( 60 | method="POST", 61 | path="/sandbox/register", 62 | json=SandboxAccountRegisterRequest( 63 | brokerAccountType=broker_account_type 64 | ).to_dict(), 65 | ) 66 | return SandboxAccount.from_dict(payload) # type: ignore 67 | 68 | async def remove( 69 | self, broker_account_id: Optional[BrokerAccountID] = None 70 | ): 71 | if broker_account_id is not None: 72 | params = {"brokerAccountId": broker_account_id} 73 | else: 74 | params = {} 75 | await self._request( 76 | method="POST", 77 | path="/sandbox/remove", 78 | params=params, 79 | ) 80 | 81 | async def clear(self, broker_account_id: Optional[BrokerAccountID] = None): 82 | if broker_account_id is not None: 83 | params = {"brokerAccountId": broker_account_id} 84 | else: 85 | params = {} 86 | await self._request( 87 | method="POST", 88 | path="/sandbox/clear", 89 | params=params, 90 | ) 91 | 92 | 93 | class SandboxAPI(BaseTinkoffInvestmentsAPI): 94 | def __init__(self, *args, **kwargs): 95 | super(SandboxAPI, self).__init__(*args, **kwargs) 96 | self.accounts = SandboxAccountsAPI(*args, **kwargs) 97 | -------------------------------------------------------------------------------- /tinkoff/investments/api/user.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | from tinkoff.investments.api.base import BaseTinkoffInvestmentsAPI 4 | from tinkoff.investments.model.user.accounts import UserAccount, UserAccounts 5 | 6 | 7 | class UserAPI(BaseTinkoffInvestmentsAPI): 8 | async def get_accounts(self) -> List[UserAccount]: 9 | payload = await self._request( 10 | method="GET", 11 | path="/user/accounts", 12 | ) 13 | return UserAccounts.from_dict(payload).accounts # type: ignore 14 | -------------------------------------------------------------------------------- /tinkoff/investments/client/__init__.py: -------------------------------------------------------------------------------- 1 | from .rest import TinkoffInvestmentsRESTClient 2 | from .streaming import EventsBroker, TinkoffInvestmentsStreamingClient 3 | 4 | __all__ = [ 5 | "TinkoffInvestmentsRESTClient", 6 | "TinkoffInvestmentsStreamingClient", 7 | "EventsBroker", 8 | ] 9 | -------------------------------------------------------------------------------- /tinkoff/investments/client/environments.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from yarl import URL 4 | 5 | 6 | class Environment(Enum): 7 | SANDBOX = "sandbox" 8 | PRODUCTION = "production" 9 | STREAMING = "streaming" 10 | 11 | 12 | EnvironmentURL = { 13 | Environment.SANDBOX: URL("https://api-invest.tinkoff.ru/openapi/sandbox/"), 14 | Environment.PRODUCTION: URL("https://api-invest.tinkoff.ru/openapi/"), 15 | Environment.STREAMING: URL( 16 | "wss://api-invest.tinkoff.ru/openapi/md/v1/md-openapi/ws" 17 | ), 18 | } 19 | 20 | 21 | __all__ = [ 22 | "Environment", 23 | "EnvironmentURL", 24 | ] 25 | -------------------------------------------------------------------------------- /tinkoff/investments/client/exceptions.py: -------------------------------------------------------------------------------- 1 | from asyncio import TimeoutError 2 | 3 | from tinkoff.investments.model.base import Error, Status 4 | 5 | 6 | class TinkoffInvestmentsError(Exception): 7 | pass 8 | 9 | 10 | class TinkoffInvestmentsUsageError(TinkoffInvestmentsError): 11 | pass 12 | 13 | 14 | class TinkoffInvestmentsUnauthorizedError(TinkoffInvestmentsError): 15 | def __str__(self): 16 | return "Have you missed the real token?" 17 | 18 | 19 | class TinkoffInvestmentsTooManyRequestsError(TinkoffInvestmentsError): 20 | pass 21 | 22 | 23 | class TinkoffInvestmentsTimeoutError(TinkoffInvestmentsError, TimeoutError): 24 | pass 25 | 26 | 27 | class TinkoffInvestmentsUnavailableError(TinkoffInvestmentsError): 28 | pass 29 | 30 | 31 | class TinkoffInvestmentsConnectionError(TinkoffInvestmentsError): 32 | pass 33 | 34 | 35 | class TinkoffInvestmentsAPIError(TinkoffInvestmentsError): 36 | def __init__(self, tracking_id: str, status: Status, error: Error): 37 | self.trackingId = tracking_id 38 | self.status = status 39 | self.error = error 40 | 41 | def __str__(self): 42 | return f"{self.error.code}, {self.error.message}" 43 | 44 | 45 | __all__ = [ 46 | "TinkoffInvestmentsError", 47 | "TinkoffInvestmentsUsageError", 48 | "TinkoffInvestmentsAPIError", 49 | "TinkoffInvestmentsUnauthorizedError", 50 | "TinkoffInvestmentsTooManyRequestsError", 51 | "TinkoffInvestmentsTimeoutError", 52 | "TinkoffInvestmentsUnavailableError", 53 | "TinkoffInvestmentsConnectionError", 54 | ] 55 | -------------------------------------------------------------------------------- /tinkoff/investments/client/rest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Any, Dict, Optional 3 | 4 | from aiohttp import ClientConnectionError 5 | 6 | from tinkoff.base import BaseHTTPClient, RateLimiter, RateLimitReached 7 | from tinkoff.investments.api import ( 8 | MarketAPI, 9 | OperationsAPI, 10 | OrdersAPI, 11 | PortfolioAPI, 12 | SandboxAPI, 13 | UserAPI, 14 | ) 15 | from tinkoff.investments.client.environments import Environment, EnvironmentURL 16 | from tinkoff.investments.client.exceptions import ( 17 | TinkoffInvestmentsConnectionError, 18 | TinkoffInvestmentsTimeoutError, 19 | TinkoffInvestmentsTooManyRequestsError, 20 | TinkoffInvestmentsUnauthorizedError, 21 | TinkoffInvestmentsUnavailableError, 22 | ) 23 | 24 | 25 | class TinkoffInvestmentsRESTClient(BaseHTTPClient): 26 | def __init__( 27 | self, 28 | token: str, 29 | environment: Environment = Environment.PRODUCTION, 30 | timeout: Optional[float] = 5, 31 | wait_on_rate_limit: bool = True, 32 | ): 33 | 34 | super(TinkoffInvestmentsRESTClient, self).__init__( 35 | base_url=EnvironmentURL[environment], 36 | headers={"authorization": f"Bearer {token}"}, 37 | timeout=timeout, 38 | ) 39 | self.sandbox = SandboxAPI(self) 40 | self.orders = OrdersAPI(self) 41 | self.portfolio = PortfolioAPI(self) 42 | self.market = MarketAPI(self) 43 | self.operations = OperationsAPI(self) 44 | self.user = UserAPI(self) 45 | self.wait_on_rate_limit = wait_on_rate_limit 46 | 47 | async def _request(self, method, path, rate_limit=None, **kwargs): 48 | # type: (str, str, Optional[RateLimiter], Any) -> Dict[Any, Any] 49 | try: 50 | if rate_limit: 51 | await rate_limit.acquire(self.wait_on_rate_limit) 52 | response = await self._session.request( 53 | method=method, url=self._base_url / path.lstrip("/"), **kwargs 54 | ) 55 | if response.status == 401: 56 | raise TinkoffInvestmentsUnauthorizedError 57 | elif response.status == 429: 58 | raise TinkoffInvestmentsTooManyRequestsError 59 | elif response.status >= 500: 60 | raise TinkoffInvestmentsUnavailableError 61 | else: 62 | # TODO: ловить другие исключения, если в ответе не json 63 | return await response.json() 64 | except asyncio.TimeoutError: 65 | raise TinkoffInvestmentsTimeoutError from None 66 | except RateLimitReached: 67 | raise TinkoffInvestmentsTooManyRequestsError from None 68 | except ClientConnectionError as e: 69 | raise TinkoffInvestmentsConnectionError(str(e)) from None 70 | 71 | async def __aenter__(self): 72 | return self 73 | 74 | async def __aexit__(self, exc_type, exc_val, exc_tb): 75 | await self.close() 76 | 77 | 78 | __all__ = ["TinkoffInvestmentsRESTClient"] 79 | -------------------------------------------------------------------------------- /tinkoff/investments/client/streaming.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from datetime import datetime 4 | from typing import Any, Callable, Dict, List, Optional, Type 5 | 6 | from aiohttp import ClientError, ClientWebSocketResponse, WSMsgType 7 | 8 | from tinkoff.base import BaseHTTPClient 9 | from tinkoff.investments.client.environments import Environment, EnvironmentURL 10 | from tinkoff.investments.model.streaming import ( 11 | BaseEvent, 12 | BaseEventKey, 13 | CandleEvent, 14 | ErrorEvent, 15 | EventName, 16 | InstrumentInfoEvent, 17 | OrderBookEvent, 18 | StreamingMessage, 19 | ) 20 | 21 | logger = logging.getLogger("streaming-api") 22 | 23 | 24 | class BaseEventStream: 25 | EVENT_TYPE: Type[BaseEvent] = None 26 | 27 | def __init__(self): 28 | self._subscribers = {} # type: Dict[BaseEventKey, Callable] 29 | self._client = ( 30 | None 31 | ) # type: Optional[TinkoffInvestmentsStreamingClient] 32 | 33 | def __call__(self, *args, **kwargs): 34 | def decorator(callback): 35 | self._subscribers[ 36 | self.EVENT_TYPE.key_type(*args, **kwargs) 37 | ] = callback 38 | return callback 39 | 40 | return decorator 41 | 42 | async def subscribe(self, callback, *args, **kwargs): 43 | key = self.EVENT_TYPE.key_type(*args, **kwargs) 44 | self._subscribers[key] = callback 45 | await self._client.request(key.subscribe_key()) 46 | 47 | async def unsubscribe(self, *args, **kwargs): 48 | key = self.EVENT_TYPE.key_type(*args, **kwargs) 49 | self._subscribers.pop(key, None) 50 | await self._client.request(key.unsubscribe_key()) 51 | 52 | async def publish(self, event: BaseEvent, server_time: datetime): 53 | callback = self._subscribers.get(event.key()) # TODO: сделать иначе 54 | if callback: 55 | await callback(event, server_time) 56 | 57 | 58 | class CandleEventStream(BaseEventStream): 59 | EVENT_TYPE = CandleEvent 60 | 61 | 62 | class OrderBookEventStream(BaseEventStream): 63 | EVENT_TYPE = OrderBookEvent 64 | 65 | 66 | class InstrumentInfoEventStream(BaseEventStream): 67 | EVENT_TYPE = InstrumentInfoEvent 68 | 69 | 70 | class ErrorEventStream(BaseEventStream): 71 | EVENT_TYPE = ErrorEvent 72 | 73 | 74 | class EventsBroker: 75 | def __init__(self): 76 | self.candles = CandleEventStream() 77 | self.orderbooks = OrderBookEventStream() 78 | self.instrument_info = InstrumentInfoEventStream() 79 | self.errors = ErrorEventStream() 80 | 81 | self._routes = { 82 | EventName.CANDLE: self.candles, 83 | EventName.ORDERBOOK: self.orderbooks, 84 | EventName.INSTRUMENT_INFO: self.instrument_info, 85 | EventName.ERROR: self.errors, 86 | } 87 | 88 | def add_publisher(self, client: "TinkoffInvestmentsStreamingClient"): 89 | self.candles._client = client 90 | self.orderbooks._client = client 91 | self.instrument_info._client = client 92 | self.errors._client = client 93 | 94 | async def publish(self, event: BaseEvent, server_time: datetime): 95 | await self._routes[event.event_name].publish(event, server_time) 96 | 97 | 98 | class TinkoffInvestmentsStreamingClient(BaseHTTPClient): 99 | def __init__( 100 | self, 101 | token: str, 102 | events: Optional[EventsBroker] = None, 103 | receive_timeout: Optional[float] = 5, 104 | heartbeat: Optional[float] = 3, 105 | reconnect_timeout: float = 3, 106 | ): 107 | super().__init__( 108 | base_url=EnvironmentURL[Environment.STREAMING], 109 | headers={"authorization": f"Bearer {token}"}, 110 | ) 111 | self.events = events or EventsBroker() 112 | self.events.add_publisher(self) 113 | self._ws = None # type: Optional[ClientWebSocketResponse] 114 | self._receive_timeout = receive_timeout 115 | self._heartbeat = heartbeat 116 | self._reconnect_timeout = reconnect_timeout 117 | 118 | async def request(self, key: Dict[str, Any]): 119 | if self._ws: 120 | await self._ws.send_json(key) 121 | 122 | async def run(self): 123 | while not self.closed: 124 | if self._session.closed: 125 | return 126 | try: 127 | async with self._session.ws_connect( 128 | url=self._base_url, 129 | timeout=0.0, 130 | receive_timeout=self._receive_timeout, 131 | heartbeat=self._heartbeat, 132 | ) as ws: 133 | self._ws = ws 134 | await self._run(ws) 135 | except asyncio.TimeoutError: 136 | await asyncio.sleep(self._reconnect_timeout) 137 | except (ClientError, ConnectionResetError): 138 | await asyncio.sleep(self._reconnect_timeout) 139 | 140 | async def _run(self, ws: ClientWebSocketResponse): 141 | await self._subscribe_to_streams(ws) 142 | async for msg in ws: 143 | # noinspection PyUnresolvedReferences 144 | if msg.type == WSMsgType.TEXT: 145 | # noinspection PyUnresolvedReferences 146 | msg = StreamingMessage.from_json(msg.data) 147 | try: 148 | await self.events.publish( 149 | event=msg.parsed_payload, 150 | server_time=msg.time, 151 | ) 152 | except Exception as e: 153 | logger.exception( 154 | "Unhandled exception in streaming event handler: %s", e 155 | ) 156 | 157 | async def _subscribe_to_streams(self, ws: ClientWebSocketResponse): 158 | coros = (ws.send_json(key) for key in self._subscription_keys()) 159 | await asyncio.gather(*coros) 160 | 161 | @property 162 | def _event_streams(self): 163 | return ( 164 | self.events.candles, 165 | self.events.orderbooks, 166 | self.events.instrument_info, 167 | ) 168 | 169 | def _subscription_keys(self) -> List[Dict[str, Any]]: 170 | keys = [] 171 | for event_stream in self._event_streams: 172 | # noinspection PyProtectedMember 173 | keys.extend( 174 | [ 175 | key.subscribe_key() 176 | for key in event_stream._subscribers.keys() 177 | ] 178 | ) 179 | return keys 180 | 181 | 182 | __all__ = ["EventsBroker", "TinkoffInvestmentsStreamingClient"] 183 | -------------------------------------------------------------------------------- /tinkoff/investments/client/streaming.pyi: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any, Callable, Dict, List, Optional 3 | 4 | from aiohttp import ClientWebSocketResponse 5 | 6 | from tinkoff.base import BaseHTTPClient 7 | from tinkoff.investments.model.base import FigiName 8 | from tinkoff.investments.model.market.candles import CandleResolution 9 | from tinkoff.investments.model.streaming import ( 10 | BaseEvent, 11 | BaseEventKey, 12 | EventName, 13 | ) 14 | 15 | class CandleEventStream: 16 | _subscribers: Dict[BaseEventKey, Callable] = ... 17 | def __call__(self, figi: FigiName, interval: CandleResolution): ... 18 | async def subscribe( 19 | self, callback, figi: FigiName, interval: CandleResolution 20 | ) -> None: ... 21 | async def unsubscribe( 22 | self, figi: FigiName, interval: CandleResolution 23 | ) -> None: ... 24 | 25 | class OrderBookEventStream: 26 | _subscribers: Dict[BaseEventKey, Callable] = ... 27 | def __call__(self, figi: FigiName, depth: int): ... 28 | async def subscribe( 29 | self, callback, figi: FigiName, depth: int 30 | ) -> None: ... 31 | async def unsubscribe(self, figi: FigiName, depth: int) -> None: ... 32 | 33 | class InstrumentInfoEventStream: 34 | _subscribers: Dict[BaseEventKey, Callable] = ... 35 | def __call__(self, figi: FigiName): ... 36 | async def subscribe(self, callback, figi: FigiName) -> None: ... 37 | async def unsubscribe(self, figi: FigiName) -> None: ... 38 | 39 | class ErrorEventStream: 40 | _subscribers: Dict[BaseEventKey, Callable] = ... 41 | def __call__(self): ... 42 | async def subscribe(self, callback) -> None: ... 43 | async def unsubscribe(self) -> None: ... 44 | 45 | class EventsBroker: 46 | candles: CandleEventStream = CandleEventStream() 47 | orderbooks: OrderBookEventStream = OrderBookEventStream() 48 | instrument_info: InstrumentInfoEventStream = InstrumentInfoEventStream() 49 | errors: ErrorEventStream = ErrorEventStream() 50 | _routes: Dict[EventName, Any] = ... 51 | def __init__(self) -> None: ... 52 | def add_publisher( 53 | self, client: TinkoffInvestmentsStreamingClient 54 | ) -> None: ... 55 | async def publish( 56 | self, event: BaseEvent, server_time: datetime 57 | ) -> None: ... 58 | 59 | class TinkoffInvestmentsStreamingClient(BaseHTTPClient): 60 | events: EventsBroker = EventsBroker() 61 | _receive_timeout: Optional[float] 62 | _heartbeat: Optional[float] 63 | _reconnect_timeout: float 64 | def __init__( 65 | self, 66 | token: str, 67 | events: Optional[EventsBroker] = None, 68 | receive_timeout: Optional[float] = 5, 69 | heartbeat: Optional[float] = 3, 70 | reconnect_timeout: float = 3, 71 | ) -> None: ... 72 | async def run(self) -> None: ... 73 | async def _run(self, ws: ClientWebSocketResponse) -> None: ... 74 | async def _subscribe_to_streams( 75 | self, ws: ClientWebSocketResponse 76 | ) -> None: ... 77 | @property 78 | def _event_streams(self): 79 | return ( 80 | self.events.candles, 81 | self.events.orderbooks, 82 | self.events.instrument_info, 83 | ) 84 | def _subscription_keys(self) -> List[Dict[str, Any]]: ... 85 | -------------------------------------------------------------------------------- /tinkoff/investments/model/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fatal1ty/tinkoff-api/64764b9c6820e78300ff8816276700959ded9177/tinkoff/investments/model/__init__.py -------------------------------------------------------------------------------- /tinkoff/investments/model/base.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | from typing import Optional 4 | 5 | from mashumaro.mixins.json import DataClassJSONMixin 6 | 7 | FigiName = str 8 | TickerName = str 9 | 10 | 11 | class BaseModel(DataClassJSONMixin): 12 | pass 13 | 14 | 15 | @dataclass 16 | class Error(BaseModel): 17 | message: Optional[str] = None 18 | code: Optional[str] = None 19 | 20 | 21 | class Status(Enum): 22 | OK = "Ok" 23 | ERROR = "Error" 24 | 25 | 26 | class Currency(Enum): 27 | RUB = "RUB" 28 | USD = "USD" 29 | EUR = "EUR" 30 | GBP = "GBP" 31 | HKD = "HKD" 32 | CHF = "CHF" 33 | JPY = "JPY" 34 | CNY = "CNY" 35 | TRY = "TRY" 36 | 37 | 38 | class InstrumentType(Enum): 39 | STOCK = "Stock" 40 | CURRENCY = "Currency" 41 | BOND = "Bond" 42 | ETF = "Etf" 43 | 44 | 45 | @dataclass 46 | class MoneyAmount(BaseModel): 47 | currency: Currency 48 | value: float 49 | -------------------------------------------------------------------------------- /tinkoff/investments/model/market/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fatal1ty/tinkoff-api/64764b9c6820e78300ff8816276700959ded9177/tinkoff/investments/model/market/__init__.py -------------------------------------------------------------------------------- /tinkoff/investments/model/market/candles.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from datetime import datetime 3 | from enum import Enum 4 | from typing import List 5 | 6 | from tinkoff.investments.model.base import BaseModel, FigiName 7 | 8 | 9 | class CandleResolution(Enum): 10 | MIN_1 = "1min" 11 | MIN_2 = "2min" 12 | MIN_3 = "3min" 13 | MIN_5 = "5min" 14 | MIN_10 = "10min" 15 | MIN_15 = "15min" 16 | MIN_30 = "30min" 17 | HOUR = "hour" 18 | DAY = "day" 19 | WEEK = "week" 20 | MONTH = "month" 21 | 22 | 23 | @dataclass 24 | class Candle(BaseModel): 25 | figi: FigiName 26 | interval: CandleResolution 27 | # TODO: сделать алиасы на понятные названия 28 | o: float 29 | c: float 30 | h: float 31 | l: float 32 | v: int 33 | time: datetime = field(metadata={"deserialize": "ciso8601"}) 34 | 35 | 36 | @dataclass 37 | class Candles(BaseModel): 38 | figi: FigiName 39 | interval: CandleResolution 40 | candles: List[Candle] 41 | 42 | 43 | __all__ = [ 44 | "CandleResolution", 45 | "Candle", 46 | "Candles", 47 | ] 48 | -------------------------------------------------------------------------------- /tinkoff/investments/model/market/instruments.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List, Optional 3 | 4 | from tinkoff.investments.model.base import ( 5 | BaseModel, 6 | Currency, 7 | FigiName, 8 | InstrumentType, 9 | TickerName, 10 | ) 11 | 12 | 13 | @dataclass 14 | class MarketInstrument(BaseModel): 15 | figi: FigiName 16 | ticker: TickerName 17 | lot: int 18 | name: str 19 | type: InstrumentType 20 | currency: Optional[Currency] = None 21 | isin: Optional[str] = None 22 | minPriceIncrement: Optional[float] = None 23 | minQuantity: Optional[int] = None 24 | 25 | 26 | @dataclass 27 | class MarketInstrumentList(BaseModel): 28 | total: int 29 | instruments: List[MarketInstrument] 30 | 31 | 32 | __all__ = [ 33 | "MarketInstrument", 34 | "MarketInstrumentList", 35 | ] 36 | -------------------------------------------------------------------------------- /tinkoff/investments/model/market/orderbook.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | from typing import List, Optional 4 | 5 | from tinkoff.investments.model.base import BaseModel, FigiName 6 | 7 | 8 | class TradingStatus(Enum): 9 | NORMAL_TRADING = "NormalTrading" 10 | NOT_AVAILABLE_FOR_TRADING = "NotAvailableForTrading" 11 | 12 | 13 | @dataclass 14 | class OrderBookEntity(BaseModel): 15 | price: float 16 | quantity: int 17 | 18 | 19 | @dataclass 20 | class OrderBook(BaseModel): 21 | figi: FigiName 22 | depth: int 23 | bids: List[OrderBookEntity] 24 | asks: List[OrderBookEntity] 25 | tradeStatus: TradingStatus 26 | minPriceIncrement: float 27 | faceValue: Optional[float] = None 28 | lastPrice: Optional[float] = None 29 | closePrice: Optional[float] = None 30 | limitUp: Optional[float] = None 31 | limitDown: Optional[float] = None 32 | 33 | 34 | __all__ = [ 35 | "TradingStatus", 36 | "OrderBookEntity", 37 | "OrderBook", 38 | ] 39 | -------------------------------------------------------------------------------- /tinkoff/investments/model/operations.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from datetime import datetime 3 | from enum import Enum 4 | from typing import List, Optional 5 | 6 | from tinkoff.investments.model.base import ( 7 | BaseModel, 8 | Currency, 9 | FigiName, 10 | InstrumentType, 11 | MoneyAmount, 12 | ) 13 | 14 | OperationID = str 15 | TradeID = str 16 | 17 | 18 | class OperationStatus(Enum): 19 | DONE = "Done" 20 | DECLINE = "Decline" 21 | PROGRESS = "Progress" 22 | 23 | 24 | class OperationType(Enum): 25 | BUY = "Buy" 26 | SELL = "Sell" 27 | 28 | 29 | class OperationTypeWithCommission(Enum): 30 | BUY = "Buy" 31 | BUY_CARD = "BuyCard" 32 | SELL = "Sell" 33 | BROKER_COMMISSION = "BrokerCommission" 34 | EXCHANGE_COMMISSION = "ExchangeCommission" 35 | SERVICE_COMMISSION = "ServiceCommission" 36 | MARGIN_COMMISSION = "MarginCommission" 37 | OTHER_COMMISSION = "OtherCommission" 38 | PAY_IN = "PayIn" 39 | PAY_OUT = "PayOut" 40 | TAX = "Tax" 41 | TAX_LUCRE = "TaxLucre" 42 | TAX_DIVIDEND = "TaxDividend" 43 | TAX_COUPON = "TaxCoupon" 44 | TAX_BACK = "TaxBack" 45 | REPAYMENT = "Repayment" 46 | PART_REPAYMENT = "PartRepayment" 47 | COUPON = "Coupon" 48 | DIVIDEND = "Dividend" 49 | SECURITY_IN = "SecurityIn" 50 | SECURITY_OUT = "SecurityOut" 51 | 52 | 53 | @dataclass 54 | class OperationTrade(BaseModel): 55 | tradeId: TradeID 56 | date: datetime = field(metadata={"deserialize": "ciso8601"}) 57 | price: float 58 | quantity: int 59 | 60 | 61 | @dataclass 62 | class Operation(BaseModel): 63 | id: OperationID 64 | status: OperationStatus 65 | currency: Currency 66 | payment: float 67 | isMarginCall: bool 68 | date: datetime = field(metadata={"deserialize": "ciso8601"}) 69 | trades: Optional[List[OperationTrade]] = None 70 | commission: Optional[MoneyAmount] = None 71 | price: Optional[float] = None 72 | quantity: Optional[int] = None 73 | figi: Optional[FigiName] = None 74 | instrumentType: Optional[InstrumentType] = None 75 | operationType: Optional[OperationTypeWithCommission] = None 76 | quantityExecuted: Optional[int] = None 77 | 78 | 79 | @dataclass 80 | class Operations(BaseModel): 81 | operations: List[Operation] 82 | 83 | 84 | __all__ = [ 85 | "OperationID", 86 | "TradeID", 87 | "OperationStatus", 88 | "OperationType", 89 | "OperationTypeWithCommission", 90 | "OperationTrade", 91 | "Operation", 92 | "Operations", 93 | ] 94 | -------------------------------------------------------------------------------- /tinkoff/investments/model/orders.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | from typing import Optional 4 | 5 | from tinkoff.investments.model.base import BaseModel, FigiName, MoneyAmount 6 | from tinkoff.investments.model.operations import OperationType 7 | 8 | OrderID = str 9 | 10 | 11 | class OrderStatus(Enum): 12 | NEW = "New" 13 | PARTIALLY_FILL = "PartiallyFill" 14 | FILL = "Fill" 15 | CANCELLED = "Cancelled" 16 | REPLACED = "Replaced" 17 | PENDING_CANCEL = "PendingCancel" 18 | REJECTED = "Rejected" 19 | PENDING_REPLACE = "PendingReplace" 20 | PENDING_NEW = "PendingNew" 21 | 22 | 23 | class OrderType(Enum): 24 | LIMIT = "Limit" 25 | MARKET = "Market" 26 | 27 | 28 | @dataclass 29 | class Order(BaseModel): 30 | orderId: OrderID 31 | figi: FigiName 32 | operation: OperationType 33 | status: OrderStatus 34 | requestedLots: int 35 | executedLots: int 36 | type: OrderType 37 | price: float 38 | 39 | 40 | @dataclass 41 | class LimitOrderRequest(BaseModel): 42 | lots: int 43 | operation: OperationType 44 | price: float 45 | 46 | 47 | @dataclass 48 | class PlacedLimitOrder(BaseModel): 49 | orderId: OrderID 50 | operation: OperationType 51 | status: OrderStatus 52 | requestedLots: int 53 | executedLots: int 54 | rejectReason: Optional[str] = None 55 | message: Optional[str] = None 56 | commission: Optional[MoneyAmount] = None 57 | 58 | 59 | @dataclass 60 | class MarketOrderRequest(BaseModel): 61 | lots: int 62 | operation: OperationType 63 | 64 | 65 | @dataclass 66 | class PlacedMarketOrder(BaseModel): 67 | orderId: OrderID 68 | operation: OperationType 69 | status: OrderStatus 70 | requestedLots: int 71 | executedLots: int 72 | rejectReason: Optional[str] = None 73 | message: Optional[str] = None 74 | commission: Optional[MoneyAmount] = None 75 | 76 | 77 | __all__ = [ 78 | "OrderID", 79 | "OrderStatus", 80 | "OrderType", 81 | "Order", 82 | "LimitOrderRequest", 83 | "PlacedLimitOrder", 84 | "MarketOrderRequest", 85 | "PlacedMarketOrder", 86 | ] 87 | -------------------------------------------------------------------------------- /tinkoff/investments/model/portfolio.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List, Optional 3 | 4 | from tinkoff.investments.model.base import ( 5 | BaseModel, 6 | Currency, 7 | FigiName, 8 | InstrumentType, 9 | MoneyAmount, 10 | TickerName, 11 | ) 12 | 13 | 14 | @dataclass 15 | class PortfolioPosition(BaseModel): 16 | figi: FigiName 17 | instrumentType: InstrumentType 18 | balance: float 19 | lots: int 20 | name: str 21 | ticker: Optional[TickerName] = None 22 | isin: Optional[str] = None 23 | blocked: Optional[float] = None 24 | expectedYield: Optional[MoneyAmount] = None 25 | averagePositionPrice: Optional[MoneyAmount] = None 26 | averagePositionPriceNoNkd: Optional[MoneyAmount] = None 27 | 28 | 29 | @dataclass 30 | class CurrencyPosition(BaseModel): 31 | currency: Currency 32 | balance: float 33 | blocked: Optional[float] = None 34 | 35 | 36 | @dataclass 37 | class Portfolio(BaseModel): 38 | positions: List[PortfolioPosition] 39 | 40 | 41 | @dataclass 42 | class Currencies(BaseModel): 43 | currencies: List[CurrencyPosition] 44 | 45 | 46 | __all__ = [ 47 | "InstrumentType", 48 | "PortfolioPosition", 49 | "CurrencyPosition", 50 | "Portfolio", 51 | "Currencies", 52 | ] 53 | -------------------------------------------------------------------------------- /tinkoff/investments/model/response.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any 3 | 4 | from .base import BaseModel, Status 5 | 6 | 7 | @dataclass 8 | class TinkoffInvestmentsAPIResponse(BaseModel): 9 | trackingId: str 10 | status: Status 11 | payload: Any 12 | 13 | def is_successful(self): 14 | return self.status is Status.OK 15 | -------------------------------------------------------------------------------- /tinkoff/investments/model/sandbox.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from tinkoff.investments.model.base import BaseModel, Currency, FigiName 4 | from tinkoff.investments.model.user.accounts import ( 5 | BrokerAccountID, 6 | BrokerAccountType, 7 | ) 8 | 9 | 10 | @dataclass 11 | class SandboxAccount(BaseModel): 12 | brokerAccountType: BrokerAccountType 13 | brokerAccountId: BrokerAccountID 14 | 15 | 16 | @dataclass 17 | class SandboxAccountRegisterRequest(BaseModel): 18 | brokerAccountType: BrokerAccountType 19 | 20 | 21 | @dataclass 22 | class SandboxSetCurrencyBalanceRequest(BaseModel): 23 | currency: Currency 24 | balance: float 25 | 26 | 27 | @dataclass 28 | class SandboxSetPositionBalanceRequest(BaseModel): 29 | figi: FigiName 30 | balance: float 31 | 32 | 33 | __all__ = [ 34 | "SandboxAccount", 35 | "SandboxAccountRegisterRequest", 36 | "SandboxSetCurrencyBalanceRequest", 37 | "SandboxSetPositionBalanceRequest", 38 | ] 39 | -------------------------------------------------------------------------------- /tinkoff/investments/model/streaming.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from datetime import datetime 3 | from enum import Enum 4 | from typing import Any, ClassVar, Dict, List, Optional, Type, TypeVar 5 | 6 | from mashumaro.mixins.json import DataClassJSONMixin 7 | 8 | from tinkoff.base import classproperty 9 | from tinkoff.investments.model.base import BaseModel, FigiName 10 | from tinkoff.investments.model.market.candles import Candle, CandleResolution 11 | from tinkoff.investments.model.market.orderbook import OrderBook 12 | 13 | 14 | class EventName(Enum): 15 | CANDLE = "candle" 16 | ORDERBOOK = "orderbook" 17 | INSTRUMENT_INFO = "instrument_info" 18 | ERROR = "error" 19 | 20 | 21 | class BaseEventKey(DataClassJSONMixin): 22 | event_name: ClassVar[EventName] 23 | 24 | def subscribe_key(self) -> Dict[str, Any]: 25 | return { 26 | "event": f"{self.event_name.value}:subscribe", 27 | **self.to_dict(), 28 | } 29 | 30 | def unsubscribe_key(self) -> Dict[str, Any]: 31 | return { 32 | "event": f"{self.event_name.value}:unsubscribe", 33 | **self.to_dict(), 34 | } 35 | 36 | 37 | @dataclass(unsafe_hash=True) 38 | class CandleEventKey(BaseEventKey): 39 | event_name = EventName.CANDLE 40 | figi: FigiName 41 | interval: CandleResolution 42 | 43 | 44 | @dataclass(unsafe_hash=True) 45 | class OrderBookEventKey(BaseEventKey): 46 | event_name = EventName.ORDERBOOK 47 | figi: FigiName 48 | depth: int 49 | 50 | 51 | @dataclass(unsafe_hash=True) 52 | class InstrumentInfoEventKey(BaseEventKey): 53 | event_name = EventName.INSTRUMENT_INFO 54 | figi: FigiName 55 | 56 | 57 | @dataclass(unsafe_hash=True) 58 | class ErrorEventKey(BaseEventKey): 59 | event_name = EventName.ERROR 60 | 61 | 62 | ConcreteEventKey = TypeVar("ConcreteEventKey", bound=BaseEventKey) 63 | 64 | 65 | class BaseEvent(BaseModel): 66 | event_name: ClassVar[EventName] 67 | 68 | @classproperty 69 | def key_type(self) -> Type[ConcreteEventKey]: 70 | return EventKeyMapping[self.event_name] 71 | 72 | def key(self): 73 | raise NotImplementedError 74 | 75 | 76 | @dataclass 77 | class CandleEvent(BaseEvent): 78 | event_name = EventName.CANDLE 79 | figi: FigiName 80 | time: datetime = field(metadata={"deserialize": "ciso8601"}) 81 | interval: CandleResolution 82 | o: float 83 | c: float 84 | h: float 85 | l: float 86 | v: int 87 | 88 | def key(self): 89 | return self.key_type(figi=self.figi, interval=self.interval) 90 | 91 | @classmethod 92 | def from_candle(cls, candle: Candle) -> "CandleEvent": 93 | return CandleEvent( 94 | figi=candle.figi, 95 | time=candle.time, 96 | interval=candle.interval, 97 | o=candle.o, 98 | c=candle.c, 99 | h=candle.h, 100 | l=candle.l, 101 | v=candle.v, 102 | ) 103 | 104 | 105 | @dataclass 106 | class OrderBookEvent(BaseEvent): 107 | event_name = EventName.ORDERBOOK 108 | figi: FigiName 109 | depth: int 110 | # TODO: сделать списком сущностей Order 111 | bids: List[List[float]] 112 | asks: List[List[float]] 113 | 114 | def key(self): 115 | return self.key_type(figi=self.figi, depth=self.depth) 116 | 117 | @classmethod 118 | def from_orderbook(cls, orderbook: OrderBook) -> "OrderBookEvent": 119 | return OrderBookEvent( 120 | figi=orderbook.figi, 121 | depth=orderbook.depth, 122 | bids=[[e.price, e.quantity] for e in orderbook.bids], 123 | asks=[[e.price, e.quantity] for e in orderbook.asks], 124 | ) 125 | 126 | 127 | @dataclass 128 | class InstrumentInfoEvent(BaseEvent): 129 | event_name = EventName.INSTRUMENT_INFO 130 | figi: FigiName 131 | min_price_increment: float 132 | lot: float 133 | trade_status: str 134 | accrued_interest: Optional[float] = None 135 | limit_up: Optional[float] = None 136 | limit_down: Optional[float] = None 137 | 138 | def key(self): 139 | return self.key_type(figi=self.figi) 140 | 141 | 142 | @dataclass 143 | class ErrorEvent(BaseEvent): 144 | event_name = EventName.ERROR 145 | error: str 146 | request_id: Optional[str] = None 147 | 148 | def key(self): 149 | return self.key_type() 150 | 151 | 152 | @dataclass 153 | class StreamingMessage(DataClassJSONMixin): 154 | event: EventName 155 | time: datetime = field(metadata={"deserialize": "ciso8601"}) 156 | payload: Dict[Any, Any] 157 | 158 | @property 159 | def parsed_payload(self): 160 | return EventMapping[self.event].from_dict(self.payload) 161 | 162 | 163 | EventMapping = { 164 | EventName.CANDLE: CandleEvent, 165 | EventName.ORDERBOOK: OrderBookEvent, 166 | EventName.INSTRUMENT_INFO: InstrumentInfoEvent, 167 | EventName.ERROR: ErrorEvent, 168 | } 169 | 170 | 171 | EventKeyMapping = { 172 | EventName.CANDLE: CandleEventKey, 173 | EventName.ORDERBOOK: OrderBookEventKey, 174 | EventName.INSTRUMENT_INFO: InstrumentInfoEventKey, 175 | EventName.ERROR: ErrorEventKey, 176 | } 177 | -------------------------------------------------------------------------------- /tinkoff/investments/model/user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fatal1ty/tinkoff-api/64764b9c6820e78300ff8816276700959ded9177/tinkoff/investments/model/user/__init__.py -------------------------------------------------------------------------------- /tinkoff/investments/model/user/accounts.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | from typing import List 4 | 5 | from tinkoff.investments.model.base import BaseModel 6 | 7 | BrokerAccountID = str 8 | 9 | 10 | class BrokerAccountType(Enum): 11 | TINKOFF = "Tinkoff" 12 | TINKOFF_IIS = "TinkoffIis" 13 | 14 | 15 | @dataclass 16 | class UserAccount(BaseModel): 17 | brokerAccountType: BrokerAccountType 18 | brokerAccountId: BrokerAccountID 19 | 20 | 21 | @dataclass 22 | class UserAccounts(BaseModel): 23 | accounts: List[UserAccount] 24 | 25 | 26 | __all__ = [ 27 | "BrokerAccountID", 28 | "BrokerAccountType", 29 | "UserAccount", 30 | "UserAccounts", 31 | ] 32 | -------------------------------------------------------------------------------- /tinkoff/investments/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | 4 | def offset_aware_datetime( 5 | dt: datetime, tzinfo: timezone = timezone.utc 6 | ) -> datetime: 7 | if dt.tzinfo: 8 | return dt 9 | else: 10 | return dt.replace(tzinfo=tzinfo) 11 | 12 | 13 | __all__ = ["offset_aware_datetime"] 14 | -------------------------------------------------------------------------------- /tinkoff/investments/utils/historical_data.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import AsyncIterator 3 | 4 | from tinkoff.investments import ( 5 | Candle, 6 | CandleResolution, 7 | FigiName, 8 | TinkoffInvestmentsRESTClient, 9 | ) 10 | from tinkoff.investments.utils import offset_aware_datetime 11 | 12 | 13 | class HistoricalData: 14 | _TIMEDELTA = { 15 | "MIN": timedelta(days=1), 16 | "HOUR": timedelta(weeks=1), 17 | "DAY": timedelta(days=365), 18 | "WEEK": timedelta(days=728), 19 | "MONTH": timedelta(days=365 * 10), 20 | } 21 | 22 | def __init__(self, client: TinkoffInvestmentsRESTClient): 23 | self._client = client 24 | 25 | async def iter_candles( 26 | self, 27 | figi: FigiName, 28 | dt_from: datetime, 29 | dt_to: datetime, 30 | interval: CandleResolution, 31 | ) -> AsyncIterator[Candle]: 32 | dt_from = offset_aware_datetime(dt_from) 33 | dt_to = offset_aware_datetime(dt_to) 34 | days = (dt_to - dt_from).days 35 | delta = self._get_timedelta(interval) 36 | for days_increment in range(0, days + 1, delta.days): 37 | dt_from_shifted = dt_from + timedelta(days=days_increment) 38 | if dt_from_shifted > offset_aware_datetime(datetime.utcnow()): 39 | break 40 | for candle in await self._client.market.candles.get( 41 | figi=figi, 42 | dt_from=dt_from_shifted, 43 | dt_to=dt_from + timedelta(days=days_increment + delta.days), 44 | interval=interval, 45 | ): 46 | if candle.time > dt_to: 47 | break 48 | yield candle 49 | await self._client.close() 50 | 51 | def _get_timedelta(self, interval: CandleResolution) -> timedelta: 52 | for key, value in self._TIMEDELTA.items(): 53 | if interval.name.startswith(key): 54 | return value 55 | raise ValueError(f"Unknown interval {interval}") 56 | -------------------------------------------------------------------------------- /tinkoff/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fatal1ty/tinkoff-api/64764b9c6820e78300ff8816276700959ded9177/tinkoff/py.typed --------------------------------------------------------------------------------