├── tests ├── unit │ ├── __init__.py │ ├── rest │ │ ├── __init__.py │ │ ├── util.py │ │ └── test_client.py │ └── websocket │ │ ├── __init__.py │ │ ├── test_channel.py │ │ └── test_client.py ├── __init__.py └── functional │ └── rest │ └── .env.sample ├── docs ├── authors.rst ├── history.rst ├── contributing.rst ├── source │ ├── modules.rst │ ├── copra.rst │ ├── copra.rest.rst │ └── copra.websocket.rst ├── license.rst ├── rest │ ├── toc.rst │ ├── examples.rst │ └── usage.rst ├── websocket │ ├── toc.rst │ ├── examples.rst │ └── usage.rst ├── contents.rst ├── Makefile ├── make.bat ├── installation.rst ├── conf.py └── index.rst ├── copra ├── rest │ └── __init__.py ├── websocket │ ├── __init__.py │ ├── channel.py │ └── client.py └── __init__.py ├── requirements_dev.txt ├── AUTHORS.rst ├── MANIFEST.in ├── tox.ini ├── .editorconfig ├── setup.cfg ├── .github └── ISSUE_TEMPLATE.md ├── .travis.yml ├── LICENSE ├── setup.py ├── .gitignore ├── HISTORY.rst ├── Makefile ├── CONTRIBUTING.rst └── README.rst /tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/rest/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/unit/websocket/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/authors.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../AUTHORS.rst 2 | -------------------------------------------------------------------------------- /docs/history.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../HISTORY.rst 2 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /copra/rest/__init__.py: -------------------------------------------------------------------------------- 1 | from copra.rest.client import APIRequestError, Client, URL, SANDBOX_URL -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Unit test package for copra.""" 4 | -------------------------------------------------------------------------------- /docs/source/modules.rst: -------------------------------------------------------------------------------- 1 | copra 2 | ===== 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | copra 8 | -------------------------------------------------------------------------------- /copra/websocket/__init__.py: -------------------------------------------------------------------------------- 1 | from copra.websocket.channel import Channel 2 | from copra.websocket.client import Client, FEED_URL, SANDBOX_FEED_URL -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | ========================================= 2 | License 3 | ========================================= 4 | .. include:: ../LICENSE 5 | -------------------------------------------------------------------------------- /docs/rest/toc.rst: -------------------------------------------------------------------------------- 1 | REST 2 | ==== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | usage 8 | examples 9 | ../source/copra.rest 10 | -------------------------------------------------------------------------------- /docs/websocket/toc.rst: -------------------------------------------------------------------------------- 1 | WebSocket 2 | ========= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | usage 8 | examples 9 | ../source/copra.websocket -------------------------------------------------------------------------------- /docs/contents.rst: -------------------------------------------------------------------------------- 1 | .. toctree:: 2 | :maxdepth: 3 3 | 4 | index 5 | installation 6 | rest/toc 7 | websocket/toc 8 | contributing 9 | authors 10 | license 11 | history 12 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pip==19.2 2 | bumpversion==0.5.3 3 | wheel==0.30.0 4 | watchdog==0.8.3 5 | flake8==3.5.0 6 | tox==2.9.1 7 | coverage==4.5.1 8 | Sphinx==1.7.1 9 | twine==1.10.0 10 | 11 | 12 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Credits 3 | ======= 4 | 5 | Development Lead 6 | ---------------- 7 | 8 | * Tony Podlaski 9 | 10 | Contributors 11 | ------------ 12 | 13 | None yet. Why not be the first? 14 | -------------------------------------------------------------------------------- /copra/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Top-level package for Coinbase Pro Asyncronous Websocket Client.""" 4 | 5 | __author__ = """Tony Podlaski""" 6 | __email__ = 'tony@podlaski.com' 7 | __version__ = '1.2.9' 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include HISTORY.rst 4 | include LICENSE 5 | include README.rst 6 | 7 | recursive-include tests * 8 | recursive-exclude * __pycache__ 9 | recursive-exclude * *.py[co] 10 | 11 | recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif 12 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py35, py36, py37, flake8 3 | 4 | [travis] 5 | python = 6 | 3.7: py37 7 | 3.6: py36 8 | 3.5: py35 9 | 10 | [testenv:flake8] 11 | basepython = python 12 | deps = flake8 13 | commands = flake8 copra 14 | 15 | [testenv] 16 | setenv = 17 | PYTHONPATH = {toxinidir} 18 | 19 | commands = python setup.py test 20 | 21 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.bat] 14 | indent_style = tab 15 | end_of_line = crlf 16 | 17 | [LICENSE] 18 | insert_final_newline = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.2.9 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | search = version='{current_version}' 8 | replace = version='{new_version}' 9 | 10 | [bumpversion:file:copra/__init__.py] 11 | search = __version__ = '{current_version}' 12 | replace = __version__ = '{new_version}' 13 | 14 | [flake8] 15 | exclude = docs 16 | 17 | [aliases] 18 | 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * Coinbase Pro Asyncronous Websocket Client version: 2 | * Python version: 3 | * Operating System: 4 | 5 | ### Description 6 | 7 | Describe what you were trying to get done. 8 | Tell us what happened, what went wrong, and what you expected to happen. 9 | 10 | ### What I Did 11 | 12 | ``` 13 | Paste the command(s) you ran and the output. 14 | If there was a crash, please include the traceback here. 15 | ``` 16 | -------------------------------------------------------------------------------- /docs/source/copra.rst: -------------------------------------------------------------------------------- 1 | copra package 2 | ============= 3 | 4 | Submodules 5 | ---------- 6 | 7 | copra.rest module 8 | ----------------- 9 | 10 | .. automodule:: copra.rest 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | copra.websocket module 16 | ---------------------- 17 | 18 | .. automodule:: copra.websocket 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | 24 | Module contents 25 | --------------- 26 | 27 | .. automodule:: copra 28 | :members: 29 | :undoc-members: 30 | :show-inheritance: 31 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = python -msphinx 7 | SPHINXPROJ = copra 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/source/copra.rest.rst: -------------------------------------------------------------------------------- 1 | ========================= 2 | REST Public API Reference 3 | ========================= 4 | 5 | The following is an API reference of CoPrA generated from Python source code and docstrings. 6 | 7 | .. warning:: 8 | This is a *complete* reference of the *public* API of CoPrA. 9 | User code and applications should only rely on the public API, since internal APIs can (and will) change without any guarantees. Anything *not* listed here is considered a private API. 10 | 11 | 12 | 13 | Module ``copra.rest`` 14 | -------------------------- 15 | 16 | .. automodule:: copra.rest 17 | 18 | .. autoclass:: Client 19 | :members: 20 | :special-members: __init__ 21 | -------------------------------------------------------------------------------- /docs/source/copra.websocket.rst: -------------------------------------------------------------------------------- 1 | ============================== 2 | WebSocket Public API Reference 3 | ============================== 4 | 5 | The following is an API reference of CoPrA generated from Python source code and docstrings. 6 | 7 | .. warning:: 8 | This is a *complete* reference of the *public* API of CoPrA. 9 | User code and applications should only rely on the public API, since internal APIs can (and will) change without any guarantees. Anything *not* listed here is considered a private API. 10 | 11 | 12 | 13 | Module ``copra.websocket`` 14 | -------------------------- 15 | 16 | .. automodule:: copra.websocket 17 | 18 | .. autoclass:: Channel 19 | :members: 20 | :special-members: __init__ 21 | 22 | .. autoclass:: Client 23 | :members: 24 | :special-members: __init__ 25 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=python -msphinx 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=copra 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The Sphinx module was not found. Make sure you have Sphinx installed, 20 | echo.then set the SPHINXBUILD environment variable to point to the full 21 | echo.path of the 'sphinx-build' executable. Alternatively you may add the 22 | echo.Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # Config file for automatic testing at travis-ci.org 2 | 3 | language: python 4 | python: 5 | - 3.6 6 | - 3.5 7 | 8 | # Enable 3.7 without globally enabling sudo and dist: xenial for other build jobs 9 | matrix: 10 | include: 11 | - python: 3.7 12 | dist: xenial 13 | sudo: true 14 | 15 | # Command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors 16 | install: pip install -U tox-travis 17 | 18 | # Command to run tests, e.g. python setup.py test 19 | script: tox 20 | 21 | # Assuming you have installed the travis-ci CLI tool, after you 22 | # create the Github repo and add it to Travis, run the 23 | # following command to finish PyPI deployment setup: 24 | # $ travis encrypt --add deploy.password 25 | #deploy: 26 | # provider: pypi 27 | # distributions: sdist bdist_wheel 28 | # user: tpodlaski 29 | # password: 30 | # secure: PLEASE_REPLACE_ME 31 | # on: 32 | # tags: true 33 | # repo: tpodlaski/copra 34 | # python: 3.6 35 | -------------------------------------------------------------------------------- /tests/functional/rest/.env.sample: -------------------------------------------------------------------------------- 1 | # This file stores semi-sensitive, user specific settings necessary for 2 | # testing the authenticated methods of copra.rest.Client in test_client.py. 3 | # 4 | # To test the authenticated methods, fill in the appropriate values (no quotes 5 | # or spaces after the =) and rename this file to .env before running 6 | # test_client.py. 7 | 8 | # Coinbase Pro Sandox API key information 9 | KEY= 10 | SECRET= 11 | PASSPHRASE= 12 | 13 | # Coinbase Pro Sandbox account ids. Can be found by running 14 | # copra.rest.Client.accounts with a sandbox-authenticated client. 15 | TEST_BTC_ACCOUNT= 16 | TEST_USD_ACCOUNT= 17 | 18 | # Coinbase Pro Sandbox USD payment method id. Can be found by running 19 | # rest.copra.Client.payment_method with a sandbox-authenticated client. 20 | TEST_USD_PAYMENT_METHOD= 21 | 22 | # Coinbase Pro Sandbox Coinbase account id. Can be found by running 23 | # rest.copra.Client.coinbase_accounts with a sandbox-authenticated client. 24 | TEST_USD_COINBASE_ACCOUNT= 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018, Tony Podlaski 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Installation 5 | ============ 6 | 7 | 8 | Stable release 9 | -------------- 10 | 11 | `pip`_ is the preferred method to install CoPrA, as it will always install the most recent stable release. To install CoPrA and its dependencies, run this command in your terminal application: 12 | 13 | .. code-block:: console 14 | 15 | $ pip install copra 16 | 17 | If you don't have `pip`_ installed, this `Python installation guide`_ can guide 18 | you through the process. 19 | 20 | .. _pip: https://pip.pypa.io 21 | .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ 22 | 23 | 24 | From sources 25 | ------------ 26 | 27 | Alternatively, the source code for the CoPrA package can be downloaded from the `Github repo`_. 28 | 29 | You can either clone the public repository: 30 | 31 | .. code-block:: console 32 | 33 | $ git clone git://github.com/tpodlaski/copra 34 | 35 | Or download the `tarball`_: 36 | 37 | .. code-block:: console 38 | 39 | $ curl -OL https://github.com/tpodlaski/copra/tarball/master 40 | 41 | Once you have a copy of the source, un-zipped and un-tarred if you downloaded the tarball, you can install it with: 42 | 43 | .. code-block:: console 44 | 45 | $ python setup.py install 46 | 47 | 48 | .. _Github repo: https://github.com/tpodlaski/copra 49 | .. _tarball: https://github.com/tpodlaski/copra/tarball/master 50 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """The setup script.""" 5 | 6 | from setuptools import setup, find_packages 7 | 8 | with open('README.rst') as readme_file: 9 | readme = readme_file.read() 10 | 11 | with open('HISTORY.rst') as history_file: 12 | history = history_file.read() 13 | 14 | requirements = ['autobahn>=18.8.1', 'aiohttp>=3.4.4', 'python-dateutil', 'python-dotenv', 'asynctest'] 15 | 16 | setup_requirements = [ ] 17 | 18 | test_requirements = [ ] 19 | 20 | setup( 21 | author="Tony Podlaski", 22 | author_email='tony@podlaski.com', 23 | classifiers=[ 24 | 'Development Status :: 5 - Production/Stable', 25 | 'Intended Audience :: Developers', 26 | 'License :: OSI Approved :: MIT License', 27 | 'Natural Language :: English', 28 | 'Programming Language :: Python :: 3', 29 | 'Programming Language :: Python :: 3.5', 30 | 'Programming Language :: Python :: 3.6', 31 | ], 32 | description="Asyncronous Python REST and WebSocket Clients for the Coinbase Pro virtual currency trading platform.", 33 | install_requires=requirements, 34 | license="MIT license", 35 | long_description=readme + '\n\n' + history, 36 | include_package_data=True, 37 | keywords='copra coinbase pro gdax api bitcoin litecoin etherium rest websocket client', 38 | name='copra', 39 | packages=['copra', 'copra.rest', 'copra.websocket'], 40 | setup_requires=setup_requirements, 41 | test_suite='tests', 42 | tests_require=test_requirements, 43 | url='https://github.com/tpodlaski/copra', 44 | version='1.2.9', 45 | zip_safe=False, 46 | ) 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | -------------------------------------------------------------------------------- /HISTORY.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | History 3 | ======= 4 | 5 | 0.1.0 (2018-07-06) 6 | ------------------ 7 | 8 | * First release on PyPI. 9 | 10 | 0.2.0 (2018-07-07) 11 | ------------------ 12 | 13 | * Added Client authentication. 14 | 15 | 0.3.0 (2018-07-09) 16 | ------------------ 17 | 18 | * Added reconnect option to Client. 19 | 20 | 0.4.0 (2018-07-10) 21 | ------------------ 22 | * Added subscribe and unsubscribe methods to Client. 23 | 24 | 1.0.0 (2018-07-12) 25 | ------------------ 26 | * Added full documentation of the CoPrA API. 27 | 28 | 1.0.1 (2018-07-12) 29 | ------------------ 30 | * Fixed typos in the documentation. 31 | 32 | 1.0.2 (2018-07-12) 33 | ------------------ 34 | * Added Examples page to the documentation. 35 | 36 | 1.0.3 (2018-07-16) 37 | ------------------ 38 | * More documentation typos fixed. 39 | 40 | 1.0.4 - 1.0.5 (2018-07-17) 41 | -------------------------- 42 | * Non-API changes. 43 | 44 | 1.0.6 (2018-08-19) 45 | ------------------ 46 | * Updated Autobahn requirement to 18.8.1 47 | 48 | 1.0.7 (2018-08-19) 49 | ------------------ 50 | * Modified Travis config to test against Python 3.7. 51 | 52 | 1.1.0 (2018-11-27) 53 | ------------------ 54 | * Added REST client. 55 | 56 | 1.1.2 (2018-12-01) 57 | ------------------ 58 | * Updated documentation formatting. 59 | 60 | 1.2.0 (2019-01-04) 61 | ------------------ 62 | * Created copra.rest package and moved old copra.rest module to 63 | copra.rest.client. 64 | * Created copra.websocket package and moved old copra.websocket module to 65 | copra.websocket.client. 66 | * Add imports to copra.rest.__init__ and copra.websocket.__init__ so that 67 | classes and attributes can still be imported as they were before. 68 | * Rewrote and completed unit tests from copra.websocket. 69 | 70 | 1.2.5 (2019-01-05) 71 | ------------------ 72 | * Updated copra.websocket.client unit tests to ignore those that are 73 | incompatible with Python 3.5 due to Mock methods that were not yet 74 | implemented. 75 | 76 | 1.2.6 (2019-01-07) 77 | ------------------ 78 | * Updated the REST client to attach an additional query string parameter 79 | to all GET requests. The parameter, 'no-cache', is a timestamp and ensures 80 | that the Coinbase server responds to all GET requests with fresh and not 81 | cached content. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean clean-test clean-pyc clean-build docs help 2 | .DEFAULT_GOAL := help 3 | 4 | define BROWSER_PYSCRIPT 5 | import os, webbrowser, sys 6 | 7 | try: 8 | from urllib import pathname2url 9 | except: 10 | from urllib.request import pathname2url 11 | 12 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) 13 | endef 14 | export BROWSER_PYSCRIPT 15 | 16 | define PRINT_HELP_PYSCRIPT 17 | import re, sys 18 | 19 | for line in sys.stdin: 20 | match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) 21 | if match: 22 | target, help = match.groups() 23 | print("%-20s %s" % (target, help)) 24 | endef 25 | export PRINT_HELP_PYSCRIPT 26 | 27 | BROWSER := python -c "$$BROWSER_PYSCRIPT" 28 | 29 | help: 30 | @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) 31 | 32 | clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts 33 | 34 | clean-build: ## remove build artifacts 35 | rm -fr build/ 36 | rm -fr dist/ 37 | rm -fr .eggs/ 38 | find . -name '*.egg-info' -exec rm -fr {} + 39 | find . -name '*.egg' -exec rm -f {} + 40 | 41 | clean-pyc: ## remove Python file artifacts 42 | find . -name '*.pyc' -exec rm -f {} + 43 | find . -name '*.pyo' -exec rm -f {} + 44 | find . -name '*~' -exec rm -f {} + 45 | find . -name '__pycache__' -exec rm -fr {} + 46 | 47 | clean-test: ## remove test and coverage artifacts 48 | rm -fr .tox/ 49 | rm -f .coverage 50 | rm -fr htmlcov/ 51 | rm -fr .pytest_cache 52 | 53 | lint: ## check style with flake8 54 | flake8 copra tests 55 | 56 | test: ## run tests quickly with the default Python 57 | python setup.py test 58 | 59 | test-all: ## run tests on every Python version with tox 60 | tox 61 | 62 | coverage: ## check code coverage quickly with the default Python 63 | coverage run --source copra setup.py test 64 | coverage report -m 65 | coverage html 66 | $(BROWSER) htmlcov/index.html 67 | 68 | docs: ## generate Sphinx HTML documentation, including API docs 69 | rm -f docs/copra.rst 70 | rm -f docs/modules.rst 71 | sphinx-apidoc -o docs/ copra 72 | $(MAKE) -C docs clean 73 | $(MAKE) -C docs html 74 | $(BROWSER) docs/_build/html/index.html 75 | 76 | servedocs: docs ## compile the docs watching for changes 77 | watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . 78 | 79 | release: dist ## package and upload a release 80 | twine upload dist/* 81 | 82 | dist: clean ## builds source and wheel package 83 | python setup.py sdist 84 | python setup.py bdist_wheel 85 | ls -l dist 86 | 87 | install: clean ## install the package to the active Python's site-packages 88 | python setup.py install 89 | -------------------------------------------------------------------------------- /copra/websocket/channel.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """WebSocket channel class for use with the copra WebSocket client. 3 | 4 | """ 5 | 6 | class Channel: 7 | """A WebSocket channel. 8 | 9 | A Channel object encapsulates the Coinbase Pro WebSocket channel name 10 | *and* one or more Coinbase Pro product ids. 11 | 12 | To read about Coinbase Pro channels and the data they return, visit: 13 | https://docs.gdax.com/#channels 14 | 15 | :ivar str name: The name of the WebSocket channel. 16 | :ivar product_ids: Product ids for the channel. 17 | :vartype product_ids: set of str 18 | """ 19 | 20 | def __init__(self, name, product_ids): 21 | """ 22 | 23 | :param str name: The name of the WebSocket channel. Possible values are 24 | heatbeat, ticker, level2, full, matches, or user 25 | 26 | :param product_ids: A single product id (eg., 'BTC-USD') or list of 27 | product ids (eg., ['BTC-USD', 'ETH-EUR', 'LTC-BTC']) 28 | :type product_ids: str or list of str 29 | 30 | :raises ValueError: If name not valid or product ids is empty. 31 | """ 32 | self.name = name.lower() 33 | if self.name not in ('heartbeat', 'ticker', 'level2', 34 | 'full', 'matches', 'user'): 35 | raise ValueError("invalid name {}".format(name)) 36 | 37 | if not product_ids: 38 | raise ValueError("must include at least one product id") 39 | 40 | if not isinstance(product_ids, list): 41 | product_ids = [product_ids] 42 | self.product_ids = set(product_ids) 43 | 44 | def __repr__(self): 45 | return str(self._as_dict()) 46 | 47 | def _as_dict(self): 48 | """Returns the Channel as a dictionary. 49 | 50 | :returns dict: The Channel as a dict with keys name & value list 51 | of product_ids. 52 | """ 53 | return {'name': self.name, 'product_ids': list(self.product_ids)} 54 | 55 | def __eq__(self, other): 56 | if self.name != other.name: 57 | raise TypeError('Channels need the same name to be compared.') 58 | return (self.name == other.name and 59 | self.product_ids == other.product_ids) 60 | 61 | def __add__(self, other): 62 | if self.name != other.name: 63 | raise TypeError('Channels need the same name to be added.') 64 | return Channel(self.name, list(self.product_ids | other.product_ids)) 65 | 66 | def __sub__(self, other): 67 | if self.name != other.name: 68 | raise TypeError('Channels need the same name to be subtracted.') 69 | product_ids = self.product_ids - other.product_ids 70 | if not product_ids: 71 | return None 72 | return Channel(self.name, list(product_ids)) -------------------------------------------------------------------------------- /tests/unit/rest/util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Utilitiy functions to be assigned as methods of asynctest.TestCase. 4 | """ 5 | 6 | import json 7 | import functools 8 | from unittest import mock 9 | from urllib.parse import parse_qsl, urlparse 10 | 11 | from multidict import MultiDict 12 | 13 | from asynctest import CoroutineMock, TestCase, patch 14 | 15 | class MockRequest(CoroutineMock): 16 | 17 | def __init__(self, name): 18 | super().__init__(name) 19 | self.side_effect = self.update 20 | 21 | 22 | def update(self, *args, **kwargs): 23 | self.args = args 24 | self.kwargs = kwargs 25 | self.headers = self.kwargs['headers'] 26 | (self.scheme, self.netloc, self.path, self.params, self.query_str, 27 | self.fragment) = urlparse(args[0]) 28 | self.url = '{}://{}{}'.format(self.scheme, self.netloc, self.path) 29 | self.query = MultiDict(parse_qsl(self.query_str)) 30 | if 'data' in self.kwargs: 31 | self.data = json.loads(self.kwargs['data']) if self.kwargs['data'] else {} 32 | else: 33 | self.data = {} 34 | return mock.DEFAULT 35 | 36 | 37 | class MockTestCase(TestCase): 38 | 39 | def setUp(self): 40 | mock_get_patcher = patch('aiohttp.ClientSession.get', new_callable=MockRequest) 41 | self.mock_get = mock_get_patcher.start() 42 | self.mock_get.method = 'GET' 43 | self.mock_get.return_value.json = CoroutineMock() 44 | self.mock_get.return_value.status = 200 45 | self.addCleanup(mock_get_patcher.stop) 46 | 47 | mock_post_patcher = patch('aiohttp.ClientSession.post', new_callable=MockRequest) 48 | self.mock_post = mock_post_patcher.start() 49 | self.mock_post.method = 'POST' 50 | self.mock_post.return_value.json = CoroutineMock() 51 | self.mock_post.return_value.status = 200 52 | self.addCleanup(mock_post_patcher.stop) 53 | 54 | mock_del_patcher = patch('aiohttp.ClientSession.delete', new_callable=MockRequest) 55 | 56 | self.mock_del = mock_del_patcher.start() 57 | self.mock_del.method = 'DEL' 58 | self.mock_del.return_value.json = CoroutineMock() 59 | self.mock_del.return_value.status = 200 60 | self.addCleanup(mock_del_patcher.stop) 61 | 62 | 63 | def check_req(self, mock_req, url='', query=None, data=None, headers=None): 64 | if not query: 65 | query = {} 66 | if not data: 67 | data = {} 68 | if not headers: 69 | headers = {} 70 | if mock_req.method == 'GET': 71 | query['no-cache'] = mock_req.query['no-cache'] 72 | self.assertEqual(mock_req.url, url) 73 | self.assertEqual(mock_req.query.items(), MultiDict(query).items()) 74 | 75 | self.assertEqual(len(mock_req.headers), len(headers)) 76 | for key, val in headers.items(): 77 | self.assertIn(key, mock_req.headers) 78 | if not val == '*': 79 | self.assertEqual(mock_req.headers[key], val) 80 | 81 | self.assertEqual(mock_req.data, data) -------------------------------------------------------------------------------- /docs/websocket/examples.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Examples 3 | ======== 4 | 5 | Ticker 6 | ------ 7 | 8 | The following code, saved as ``ticker.py``, when run from the command line 9 | prints a running ticker for the product ID supplied as an argument to the 10 | script. 11 | 12 | .. code:: python 13 | 14 | #!/usr/bin/env python3 15 | 16 | import asyncio 17 | from datetime import datetime 18 | import sys 19 | 20 | from copra.websocket import Channel, Client 21 | 22 | class Tick: 23 | 24 | def __init__(self, tick_dict): 25 | self.product_id = tick_dict['product_id'] 26 | self.best_bid = float(tick_dict['best_bid']) 27 | self.best_ask = float(tick_dict['best_ask']) 28 | self.price = float(tick_dict['price']) 29 | self.side = tick_dict['side'] 30 | self.size = float(tick_dict['last_size']) 31 | self.time = datetime.strptime(tick_dict['time'], '%Y-%m-%dT%H:%M:%S.%fZ') 32 | 33 | @property 34 | def spread(self): 35 | return self.best_ask - self.best_bid 36 | 37 | def __repr__(self): 38 | 39 | rep = "{}\t\t\t\t {}\n".format(self.product_id, self.time) 40 | rep += "=============================================================\n" 41 | rep += " Price: ${:.2f}\t Size: {:.8f}\t Side: {: >5}\n".format(self.price, self.size, self.side) 42 | rep += "Best ask: ${:.2f}\tBest bid: ${:.2f}\tSpread: ${:.2f}\n".format(self.best_ask, self.best_bid, self.spread) 43 | rep += "=============================================================\n" 44 | return rep 45 | 46 | 47 | class Ticker(Client): 48 | 49 | def on_message(self, message): 50 | if message['type'] == 'ticker' and 'time' in message: 51 | tick = Tick(message) 52 | print(tick, "\n\n") 53 | 54 | product_id = sys.argv[1] 55 | 56 | loop = asyncio.get_event_loop() 57 | 58 | channel = Channel('ticker', product_id) 59 | 60 | ticker = Ticker(loop, channel) 61 | 62 | try: 63 | loop.run_forever() 64 | except KeyboardInterrupt: 65 | loop.run_until_complete(ticker.close()) 66 | loop.close() 67 | 68 | Streaming a ticker for LTC-USD: 69 | 70 | .. code:: bash 71 | 72 | $ ./ticker.py LTC-USD 73 | 74 | LTC-USD 2018-07-12 21:40:38.501000 75 | ============================================================= 76 | Price: $75.73 Size: 0.22134981 Side: buy 77 | Best ask: $75.73 Best bid: $75.67 Spread: $0.06 78 | ============================================================= 79 | 80 | 81 | 82 | LTC-USD 2018-07-12 21:40:38.501000 83 | ============================================================= 84 | Price: $75.74 Size: 0.29362708 Side: buy 85 | Best ask: $75.74 Best bid: $75.67 Spread: $0.07 86 | ============================================================= 87 | 88 | 89 | 90 | LTC-USD 2018-07-12 21:40:41.202000 91 | ============================================================= 92 | Price: $75.68 Size: 0.19211000 Side: sell 93 | Best ask: $75.74 Best bid: $75.68 Spread: $0.06 94 | ============================================================= 95 | 96 | 97 | 98 | LTC-USD 2018-07-12 21:41:09.452000 99 | ============================================================= 100 | Price: $75.71 Size: 0.63097536 Side: buy 101 | Best ask: $75.71 Best bid: $75.68 Spread: $0.03 102 | ============================================================= 103 | 104 | ^C 105 | $ 106 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | .. highlight:: shell 2 | 3 | ============ 4 | Contributing 5 | ============ 6 | 7 | Contributions are welcome, and they are greatly appreciated! Every little bit 8 | helps, and credit will always be given. 9 | 10 | You can contribute in many ways: 11 | 12 | Types of Contributions 13 | ---------------------- 14 | 15 | Report Bugs 16 | ~~~~~~~~~~~ 17 | 18 | Report bugs at https://github.com/tpodlaski/copra/issues. 19 | 20 | If you are reporting a bug, please include: 21 | 22 | * Your operating system name and version. 23 | * Any details about your local setup that might be helpful in troubleshooting. 24 | * Detailed steps to reproduce the bug. 25 | 26 | Fix Bugs 27 | ~~~~~~~~ 28 | 29 | Look through the GitHub issues for bugs. Anything tagged with "bug" and "help 30 | wanted" is open to whoever wants to implement it. 31 | 32 | Implement Features 33 | ~~~~~~~~~~~~~~~~~~ 34 | 35 | Look through the GitHub issues for features. Anything tagged with "enhancement" 36 | and "help wanted" is open to whoever wants to implement it. 37 | 38 | Write Documentation 39 | ~~~~~~~~~~~~~~~~~~~ 40 | 41 | Coinbase Pro Asyncronous Websocket Client could always use more documentation, whether as part of the 42 | official Coinbase Pro Asyncronous Websocket Client docs, in docstrings, or even on the web in blog posts, 43 | articles, and such. 44 | 45 | Submit Feedback 46 | ~~~~~~~~~~~~~~~ 47 | 48 | The best way to send feedback is to file an issue at https://github.com/tpodlaski/copra/issues. 49 | 50 | If you are proposing a feature: 51 | 52 | * Explain in detail how it would work. 53 | * Keep the scope as narrow as possible, to make it easier to implement. 54 | * Remember that this is a volunteer-driven project, and that contributions 55 | are welcome :) 56 | 57 | Get Started! 58 | ------------ 59 | 60 | Ready to contribute? Here's how to set up `copra` for local development. 61 | 62 | 1. Fork the `copra` repo on GitHub. 63 | 2. Clone your fork locally:: 64 | 65 | $ git clone git@github.com:your_name_here/copra.git 66 | 67 | 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: 68 | 69 | $ mkvirtualenv copra 70 | $ cd copra/ 71 | $ python setup.py develop 72 | 73 | 4. Create a branch for local development:: 74 | 75 | $ git checkout -b name-of-your-bugfix-or-feature 76 | 77 | Now you can make your changes locally. 78 | 79 | 5. When you're done making changes, check that your changes pass flake8 and the 80 | tests, including testing other Python versions with tox:: 81 | 82 | $ flake8 copra tests 83 | $ python setup.py test or py.test 84 | $ tox 85 | 86 | To get flake8 and tox, just pip install them into your virtualenv. 87 | 88 | 6. Commit your changes and push your branch to GitHub:: 89 | 90 | $ git add . 91 | $ git commit -m "Your detailed description of your changes." 92 | $ git push origin name-of-your-bugfix-or-feature 93 | 94 | 7. Submit a pull request through the GitHub website. 95 | 96 | Pull Request Guidelines 97 | ----------------------- 98 | 99 | Before you submit a pull request, check that it meets these guidelines: 100 | 101 | 1. The pull request should include tests. 102 | 2. If the pull request adds functionality, the docs should be updated. Put 103 | your new functionality into a function with a docstring, and add the 104 | feature to the list in README.rst. 105 | 3. The pull request should work for Python 2.7, 3.4, 3.5 and 3.6, and for PyPy. Check 106 | https://travis-ci.org/tpodlaski/copra/pull_requests 107 | and make sure that the tests pass for all supported Python versions. 108 | 109 | Tips 110 | ---- 111 | 112 | To run a subset of tests:: 113 | 114 | 115 | $ python -m unittest tests.test_copra 116 | 117 | Deploying 118 | --------- 119 | 120 | A reminder for the maintainers on how to deploy. 121 | Make sure all your changes are committed (including an entry in HISTORY.rst). 122 | Then run:: 123 | 124 | $ bumpversion patch # possible: major / minor / patch 125 | $ git push 126 | $ git push --tags 127 | 128 | Travis will then deploy to PyPI if tests pass. 129 | -------------------------------------------------------------------------------- /tests/unit/websocket/test_channel.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Tests for `copra.websocket.channel` module.""" 5 | 6 | import unittest 7 | 8 | from copra.websocket import Channel 9 | 10 | 11 | class TestChannel(unittest.TestCase): 12 | """Tests for cbprotk.websocket.Channel""" 13 | 14 | def setUp(self): 15 | """Set up test fixtures, if any.""" 16 | 17 | def tearDown(self): 18 | """Tear down test fixtures, if any.""" 19 | 20 | def test__init__(self): 21 | #no channel name, no product_ids 22 | with self.assertRaises(TypeError): 23 | channel = Channel() 24 | 25 | #channel name, no product_ids: 26 | with self.assertRaises(TypeError): 27 | channel = Channel('heartbeat') 28 | 29 | #valid name 30 | channel = Channel('heartbeat', 'BTC-USD') 31 | self.assertEqual(channel.name, 'heartbeat') 32 | 33 | #invalid name 34 | with self.assertRaises(ValueError): 35 | channel = Channel('pulse', 'BTC-USD') 36 | 37 | #lower case 38 | channel = Channel('TiCKeR', 'BTC-USD') 39 | self.assertEqual(channel.name, 'ticker') 40 | 41 | #product_ids as str 42 | channel = Channel('heartbeat', 'BTC-USD') 43 | self.assertIsInstance(channel.product_ids, set) 44 | self.assertEqual(channel.product_ids, set(['BTC-USD'])) 45 | 46 | #product_ids as list length 1 47 | channel = Channel('heartbeat', ['BTC-USD']) 48 | self.assertIsInstance(channel.product_ids, set) 49 | self.assertEqual(channel.product_ids, set(['BTC-USD'])) 50 | 51 | #product_ids as list length 2 52 | channel = Channel('heartbeat', ['BTC-USD', 'LTC-USD']) 53 | self.assertIsInstance(channel.product_ids, set) 54 | self.assertEqual(channel.product_ids, set(['BTC-USD', 'LTC-USD'])) 55 | 56 | #empty product_ids string 57 | with self.assertRaises(ValueError): 58 | channel = Channel('heartbeat', '') 59 | 60 | #empty product_ids list 61 | with self.assertRaises(ValueError): 62 | channel = Channel('heartbeat', []) 63 | 64 | def test__as_dict(self): 65 | channel = Channel('heartbeat', ['BTC-USD', 'LTC-USD']) 66 | d = channel._as_dict() 67 | self.assertIsInstance(d, dict) 68 | self.assertEqual(d['name'], 'heartbeat') 69 | self.assertEqual(len(d['product_ids']), 2) 70 | self.assertIn('BTC-USD', d['product_ids']) 71 | self.assertIn('LTC-USD', d['product_ids']) 72 | 73 | def test___eq__(self): 74 | channel1 = Channel('heartbeat', ['BTC-USD', 'LTC-USD']) 75 | channel2 = Channel('heartbeat', ['BTC-USD', 'LTC-USD']) 76 | channel3 = Channel('heartbeat', 'BTC-USD') 77 | channel4 = Channel('heartbeat', ['BTC-USD']) 78 | channel5 = Channel('ticker', ['BTC-USD']) 79 | 80 | self.assertEqual(channel1, channel2) 81 | self.assertEqual(channel3, channel4) 82 | self.assertNotEqual(channel1, channel3) 83 | with self.assertRaises(TypeError): 84 | comp = (channel4 == channel5) 85 | 86 | def test___add__(self): 87 | channel1 = Channel('heartbeat', ['BTC-USD', 'LTC-USD']) 88 | channel2 = Channel('heartbeat', ['BTC-EUR', 'LTC-EUR']) 89 | channel3 = Channel('heartbeat', 'BTC-USD') 90 | channel4 = Channel('ticker', ['BTC-EUR', 'LTC-EUR']) 91 | 92 | channel = channel1 + channel2 93 | self.assertEqual(channel.name, 'heartbeat') 94 | self.assertEqual(channel.product_ids, {'BTC-USD', 'LTC-USD', 'BTC-EUR', 'LTC-EUR'}) 95 | 96 | channel = channel1 + channel3 97 | self.assertEqual(channel.name, "heartbeat") 98 | self.assertEqual(channel.product_ids, {'BTC-USD', 'LTC-USD'}) 99 | 100 | channel1 += channel2 101 | self.assertEqual(channel1.name, 'heartbeat') 102 | self.assertEqual(channel1.product_ids, {'BTC-USD', 'LTC-USD', 'BTC-EUR', 'LTC-EUR'}) 103 | 104 | with self.assertRaises(TypeError): 105 | channel = channel1 + channel4 106 | 107 | def test___sub__(self): 108 | channel1 = Channel('heartbeat', ['BTC-USD', 'LTC-USD']) 109 | channel2 = Channel('heartbeat', ['BTC-USD', 'LTC-USD']) 110 | channel3 = Channel('heartbeat', ['LTC-USD']) 111 | channel4 = Channel('ticker', ['LTC-USD']) 112 | 113 | self.assertIsNone(channel1 - channel2) 114 | 115 | channel = channel1 - channel3 116 | self.assertEqual(channel.name, 'heartbeat') 117 | self.assertEqual(channel.product_ids, {'BTC-USD'}) 118 | 119 | with self.assertRaises(TypeError): 120 | channel = channel1 - channel4 121 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # copra documentation build configuration file, created by 5 | # sphinx-quickstart on Fri Jun 9 13:47:02 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another 17 | # directory, add these directories to sys.path here. If the directory is 18 | # relative to the documentation root, use os.path.abspath to make it 19 | # absolute, like shown here. 20 | # 21 | import os 22 | import sys 23 | import time 24 | sys.path.insert(0, os.path.abspath('..')) 25 | 26 | import copra 27 | 28 | # -- General configuration --------------------------------------------- 29 | 30 | # If your documentation needs a minimal Sphinx version, state it here. 31 | # 32 | # needs_sphinx = '1.0' 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be 35 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 36 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] 37 | 38 | autodoc_mock_imports = ["aiohttp", "autobahn", "dateutil", "multidict"] 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ['_templates'] 42 | 43 | # The suffix(es) of source filenames. 44 | # You can specify multiple suffix as a list of string: 45 | # 46 | # source_suffix = ['.rst', '.md'] 47 | source_suffix = '.rst' 48 | 49 | # The master toctree document. 50 | master_doc = 'contents' 51 | 52 | # General information about the project. 53 | project = u'CoPrA' 54 | author = u"Tony Podlaski" 55 | this_year = u'{0}'.format(time.strftime('%Y')) 56 | if this_year != u'2018': 57 | copyright = u'2018-{0}, Tony Podlaski'.format(this_year) 58 | else: 59 | copyright = u'2018, Tony Podlaski' 60 | 61 | # The version info for the project you're documenting, acts as replacement 62 | # for |version| and |release|, also used in various other places throughout 63 | # the built documents. 64 | # 65 | # The short X.Y version. 66 | version = copra.__version__ 67 | # The full version, including alpha/beta/rc tags. 68 | release = copra.__version__ 69 | 70 | # The language for content autogenerated by Sphinx. Refer to documentation 71 | # for a list of supported languages. 72 | # 73 | # This is also used if you do content translation via gettext catalogs. 74 | # Usually you set "language" from the command line for these cases. 75 | language = 'en' 76 | 77 | # List of patterns, relative to source directory, that match files and 78 | # directories to ignore when looking for source files. 79 | # This patterns also effect to html_static_path and html_extra_path 80 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 81 | 82 | # The name of the Pygments (syntax highlighting) style to use. 83 | pygments_style = 'sphinx' 84 | 85 | # If true, `todo` and `todoList` produce output, else they produce nothing. 86 | todo_include_todos = False 87 | 88 | 89 | # -- Options for HTML output ------------------------------------------- 90 | 91 | # The theme to use for HTML and HTML Help pages. See the documentation for 92 | # a list of builtin themes. 93 | # 94 | html_theme = 'sphinx_rtd_theme' 95 | 96 | # Theme options are theme-specific and customize the look and feel of a 97 | # theme further. For a list of options available for each theme, see the 98 | # documentation. 99 | # 100 | # html_theme_options = {} 101 | 102 | # Add any paths that contain custom static files (such as style sheets) here, 103 | # relative to this directory. They are copied after the builtin static files, 104 | # so a file named "default.css" will overwrite the builtin "default.css". 105 | html_static_path = ['_static'] 106 | 107 | 108 | # -- Options for HTMLHelp output --------------------------------------- 109 | 110 | # Output file base name for HTML help builder. 111 | htmlhelp_basename = 'copradoc' 112 | 113 | 114 | # -- Options for LaTeX output ------------------------------------------ 115 | 116 | latex_elements = { 117 | # The paper size ('letterpaper' or 'a4paper'). 118 | # 119 | # 'papersize': 'letterpaper', 120 | 121 | # The font size ('10pt', '11pt' or '12pt'). 122 | # 123 | # 'pointsize': '10pt', 124 | 125 | # Additional stuff for the LaTeX preamble. 126 | # 127 | # 'preamble': '', 128 | 129 | # Latex figure (float) alignment 130 | # 131 | # 'figure_align': 'htbp', 132 | } 133 | 134 | # Grouping the document tree into LaTeX files. List of tuples 135 | # (source start file, target name, title, author, documentclass 136 | # [howto, manual, or own class]). 137 | latex_documents = [ 138 | (master_doc, 'copra.tex', 139 | u'Coinbase Pro Asyncronous Websocket Client Documentation', 140 | u'Tony Podlaski', 'manual'), 141 | ] 142 | 143 | 144 | # -- Options for manual page output ------------------------------------ 145 | 146 | # One entry per manual page. List of tuples 147 | # (source start file, name, description, authors, manual section). 148 | man_pages = [ 149 | (master_doc, 'copra', 150 | u'Coinbase Pro Asyncronous Websocket Client Documentation', 151 | [author], 1) 152 | ] 153 | 154 | 155 | # -- Options for Texinfo output ---------------------------------------- 156 | 157 | # Grouping the document tree into Texinfo files. List of tuples 158 | # (source start file, target name, title, author, 159 | # dir menu entry, description, category) 160 | texinfo_documents = [ 161 | (master_doc, 'copra', 162 | u'Coinbase Pro Asyncronous Websocket Client Documentation', 163 | author, 164 | 'copra', 165 | 'One line description of project.', 166 | 'Miscellaneous'), 167 | ] 168 | -------------------------------------------------------------------------------- /docs/rest/examples.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Examples 3 | ======== 4 | 5 | Market Order 6 | ------------ 7 | 8 | The following example places a market order for the minimum amount of BTC-USD possible and then follows up by placing a stop loss market order at a stop price that is $300 less than the original order price. 9 | 10 | This is a contrived example that makes some assumptions and does not count for every contingency, but it does use several ``copra.rest.Client`` methods. The steps that is follows are: 11 | 12 | 1. Check to see what the minimum BTC-USD order size is. :meth:`products() ` 13 | 2. Check to see what the available USD balance is. :meth:`accounts() ` 14 | 3. Check for the price of the last BTC-USD trade. :meth:`ticker() ` 15 | 4. Place a market order. :meth:`market_order() ` 16 | 5. Check the order status. :meth:`get_order() ` 17 | 6. Place a stop loss market order. :meth:`market_order() ` 18 | 19 | .. code:: python 20 | 21 | # rest_example.py 22 | 23 | import asyncio 24 | 25 | from copra.rest import APIRequestError, Client 26 | 27 | KEY = YOUR_KEY 28 | SECRET = YOUR_SECRET 29 | PASSPHRASE = YOUR_PASSPHRASE 30 | 31 | loop = asyncio.get_event_loop() 32 | 33 | async def run(): 34 | 35 | async with Client(loop, auth=True, key=KEY, secret=SECRET, 36 | passphrase=PASSPHRASE) as client: 37 | 38 | # Determine the smallest size order of BTC-USD possible. 39 | products = await client.products() 40 | for product in products: 41 | if product['id'] == 'BTC-USD': 42 | min_btc_size = float(product['base_min_size']) 43 | break 44 | 45 | print('\nMinimum BTC-USD order size: {}\n'.format(min_btc_size)) 46 | 47 | # Get the amount of USD you have available. This assumes you don't 48 | # know the account id of your Coinbase Pro USD account. If you did, 49 | # you could just call client.account(account_id) to retrieve the 50 | # amount of USD available. 51 | accounts = await client.accounts() 52 | for account in accounts: 53 | if account['currency'] == 'USD': 54 | usd_available = float(account['available']) 55 | 56 | print('USD available: ${}\n'.format(usd_available)) 57 | 58 | # Get the last price of BTC-USD 59 | btc_usd_ticker = await client.ticker('BTC-USD') 60 | btc_usd_price = float(btc_usd_ticker['price']) 61 | 62 | print("Last BTC-USD price: ${}\n".format(btc_usd_price)) 63 | 64 | # Verify you have enough USD to place the minimum BTC-USD order 65 | usd_needed = btc_usd_price * min_btc_size 66 | if usd_available < usd_needed: 67 | print('Sorry, you need ${} to place the minimum BTC order'.format( 68 | usd_needed)) 69 | return 70 | 71 | # Place a market order for the minimum amount of BTC 72 | try: 73 | order = await client.market_order('buy', 'BTC-USD', 74 | size=min_btc_size) 75 | order_id = order['id'] 76 | 77 | print('Market order placed.') 78 | print('\tOrder id: {}'.format(order_id)) 79 | print('\tSize: {}\n'.format(order['size'])) 80 | 81 | except APIRequestError as e: 82 | print(e) 83 | return 84 | 85 | # Wait a few seconds just to make sure the order completes. 86 | await asyncio.sleep(5) 87 | 88 | # Check the order status 89 | order = await client.get_order(order_id) 90 | 91 | # Assume the order is done and not rejected. 92 | order_size = float(order['filled_size']) 93 | order_executed_value = float(order['executed_value']) 94 | 95 | # We could check the fills to get the price(s) the order was 96 | # executed at, but we'll just use the average price. 97 | order_price = order_size * order_executed_value 98 | 99 | print('{} BTC bought at ${:.2f} for ${:.2f}\n'.format(order_size, 100 | order_price, 101 | order_executed_value)) 102 | 103 | # Place a stop loss market order at $300 below the order price. 104 | stop_price = '{:.2f}'.format(6680.55 - 300) 105 | sl_order = await client.market_order('sell', 'BTC-USD', order_size, 106 | stop='loss', stop_price=stop_price) 107 | 108 | print('Stop loss order placed.') 109 | print('\tOrder id: {}'.format(sl_order['id'])) 110 | print('\tSize: {}'.format(sl_order['size'])) 111 | print('\tStop price: ${:.2f}\n'.format(float(sl_order['stop_price']))) 112 | 113 | await client.cancel_all(stop=True) 114 | 115 | loop.run_until_complete(run()) 116 | 117 | loop.close() 118 | 119 | Running this script with your API key credentials inserted in their proper spots should yield output similar to that below. 120 | 121 | .. code:: bash 122 | 123 | $ python3 rest_example.py 124 | 125 | Minimum BTC-USD order size: 0.001 126 | 127 | USD available: $1485.6440517304 128 | 129 | Last BTC-USD price: $6571.00 130 | 131 | Market order placed. 132 | Order id: 1ed693ef-fc95-49ec-af6e-d37937d5ff1b 133 | Size: 0.00100000 134 | 135 | 0.001 BTC bought at $6571.00 for $6.57 136 | 137 | Stop loss order placed. 138 | Order id: 72998d92-dea2-4f4c-83f2-e119f92861d5 139 | Size: 0.00100000 140 | Stop price: $6271.00 141 | 142 | $ 143 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ========================================= 2 | CoPrA 3 | ========================================= 4 | 5 | *Asyncronous Python REST and WebSocket Clients for Coinbase Pro* 6 | 7 | ----------------------------------------- 8 | 9 | | |Version| |Build Status| |Docs| 10 | 11 | | 12 | 13 | | **Quick Links**: `Documentation `__ - `Source Code `__ - `PyPi `__ 14 | 15 | | **Related**: `Coinbase Pro Digital Currency Exchange `__ - `Coinbase Pro REST API `_ - `Coinbase Pro WebSocket API `_ 16 | 17 | ----------------------------------------- 18 | 19 | Introduction 20 | ------------ 21 | 22 | The CoPrA \(**Co**\ inbase **Pr**\ o **A**\ sync\) package provides asyncronous REST and WebSocket clients written in Python for use with the Coinbase Pro digital currency trading platform. To learn about Coinbase Pro's REST and WebSocket APIs as well as how to obtain an API key for authentication to those services, please see `Coinbase Pro's API documentation `__. 23 | 24 | CoPrA Features 25 | -------------- 26 | 27 | * compatible with Python 3.5 or greater 28 | * utilizes Python's `asyncio `__ concurrency framework 29 | * open source (`MIT `__ license) 30 | 31 | REST Features 32 | +++++++++++++ 33 | 34 | * Asyncronous REST client class with 100% of the account management, trading, and market data functionality offered by the Coinbase Pro REST API. 35 | * supports user authentication 36 | * built on **aiohttp**, the asynchronous HTTP client/server framework for asyncio and Python 37 | 38 | WebSocket Features 39 | ++++++++++++++++++ 40 | 41 | * Asyncronous WebSocket client class with callback hooks for managing every phase of a Coinbase Pro WebSocket session 42 | * supports user authentication 43 | * built on **Autobahn|Python**, the open-source (MIT) real-time framework for web, mobile & the Internet of Things. 44 | 45 | Examples 46 | -------- 47 | 48 | REST 49 | ++++ 50 | Without a Coinbase Pro API key, ``copra.rest.Client`` has access to all of the public market data that Coinbase makes available. 51 | 52 | .. code:: python 53 | 54 | # 24hour_stats.py 55 | 56 | import asyncio 57 | 58 | from copra.rest import Client 59 | 60 | loop = asyncio.get_event_loop() 61 | 62 | client = Client(loop) 63 | 64 | async def get_stats(): 65 | btc_stats = await client.get_24hour_stats('BTC-USD') 66 | print(btc_stats) 67 | 68 | loop.run_until_complete(get_stats()) 69 | loop.run_until_complete(client.close()) 70 | 71 | Running the above: 72 | 73 | .. code:: bash 74 | 75 | $ python3 24hour_stats.py 76 | {'open': '3914.96000000', 'high': '3957.10000000', 'low': '3508.00000000', 'volume': '37134.10720409', 'last': '3670.06000000', 'volume_30day': '423047.53794129'} 77 | 78 | In conjunction with a Coinbase Pro API key, ``copra.rest.Client`` can be used to trade cryptocurrency and manage your Coinbase pro account. This example also shows how ``copra.rest.Client`` can be used as a context manager. 79 | 80 | .. code:: python 81 | 82 | # btc_account_info.py 83 | 84 | import asyncio 85 | 86 | from copra.rest import Client 87 | 88 | KEY = YOUR_API_KEY 89 | SECRET = YOUR_API_SECRET 90 | PASSPHRASE = YOUR_API_PASSPHRASE 91 | 92 | BTC_ACCOUNT_ID = YOUR_BTC_ACCOUNT_ID 93 | 94 | loop = asyncio.get_event_loop() 95 | 96 | async def get_btc_account(): 97 | async with Client(loop, auth=True, key=KEY, 98 | secret=SECRET, passphrase=PASSPHRASE) as client: 99 | 100 | btc_account = await client.account(BTC_ACCOUNT_ID) 101 | print(btc_account) 102 | 103 | loop.run_until_complete(get_btc_account()) 104 | 105 | Running the above: 106 | 107 | .. code:: bash 108 | 109 | $ python3 btc_account_info.py 110 | {'id': '1b121cbe-bd4-4c42-9e31-7047632fc7c7', 'currency': 'BTC', 'balance': '26.1023109600000000', 'available': '26.09731096', 'hold': '0.0050000000000000', 'profile_id': '151d9abd-abcc-4597-ae40-b6286d72a0bd'} 111 | 112 | WebSocket 113 | +++++++++ 114 | 115 | While ``copra.websocket.Client`` is meant to be overridden, but it can be used 'as is' to test the module through the command line. 116 | 117 | .. code:: python 118 | 119 | # btc_heartbeat.py 120 | 121 | import asyncio 122 | 123 | from copra.websocket import Channel, Client 124 | 125 | loop = asyncio.get_event_loop() 126 | 127 | ws = Client(loop, Channel('heartbeat', 'BTC-USD')) 128 | 129 | try: 130 | loop.run_forever() 131 | except KeyboardInterrupt: 132 | loop.run_until_complete(ws.close()) 133 | loop.close() 134 | 135 | Running the above: 136 | 137 | .. code:: bash 138 | 139 | $ python3 btc_heartbeat.py 140 | {'type': 'subscriptions', 'channels': [{'name': 'heartbeat', 'product_ids': ['BTC-USD']}]} 141 | {'type': 'heartbeat', 'last_trade_id': 45950713, 'product_id': 'BTC-USD', 'sequence': 6254273323, 'time': '2018-07-05T22:36:30.823000Z'} 142 | {'type': 'heartbeat', 'last_trade_id': 45950714, 'product_id': 'BTC-USD', 'sequence': 6254273420, 'time': '2018-07-05T22:36:31.823000Z'} 143 | {'type': 'heartbeat', 'last_trade_id': 45950715, 'product_id': 'BTC-USD', 'sequence': 6254273528, 'time': '2018-07-05T22:36:32.823000Z'} 144 | {'type': 'heartbeat', 'last_trade_id': 45950715, 'product_id': 'BTC-USD', 'sequence': 6254273641, 'time': '2018-07-05T22:36:33.823000Z'} 145 | {'type': 'heartbeat', 'last_trade_id': 45950715, 'product_id': 'BTC-USD', 'sequence': 6254273758, 'time': '2018-07-05T22:36:34.823000Z'} 146 | {'type': 'heartbeat', 'last_trade_id': 45950720, 'product_id': 'BTC-USD', 'sequence': 6254273910, 'time': '2018-07-05T22:36:35.824000Z'} 147 | . 148 | . 149 | . 150 | 151 | A Coinbase Pro API key allows ``copra.websocket.Client`` to authenticate with the Coinbase WebSocket server giving you access to feeds specific to your user account. 152 | 153 | .. code:: python 154 | 155 | # user_channel.py 156 | 157 | import asyncio 158 | 159 | from copra.websocket import Channel, Client 160 | 161 | KEY = YOUR_API_KEY 162 | SECRET = YOUR_API_SECRET 163 | PASSPHRASE = YOUR_API_PASSPHRASE 164 | 165 | loop = asyncio.get_event_loop() 166 | 167 | channel = Channel('user', 'LTC-USD') 168 | 169 | ws = Client(loop, channel, auth=True, key=KEY, secret=SECRET, passphrase=PASSPHRASE) 170 | 171 | try: 172 | loop.run_forever() 173 | except KeyboardInterrupt: 174 | loop.run_until_complete(ws.close()) 175 | loop.close() 176 | 177 | 178 | Running the above: 179 | 180 | .. code:: bash 181 | 182 | $ python3 user_channel.py 183 | {'type': 'subscriptions', 'channels': [{'name': 'user', 'product_ids': ['LTC-USD']}]} 184 | {'type': 'received', 'order_id': '42d2677d-0d37-435f-a776-e9e7f81ff22b', 'order_type': 'limit', 'size': '50.00000000', 'price': '1.00000000', 'side': 'buy', 'client_oid': '00098b59-4ac9-4ff8-ba16-bd2ef673f7b7', 'product_id': 'LTC-USD', 'sequence': 2311323871, 'user_id': '642394321fdf8242c4006432', 'profile_id': '039ee148-d490-44f9-9aed-0d1f6412884', 'time': '2018-07-07T17:33:29.755000Z'} 185 | {'type': 'open', 'side': 'buy', 'price': '1.00000000', 'order_id': '42d2677d-0d37-435f-a776-e9e7f81ff22b', 'remaining_size': '50.00000000', 'product_id': 'LTC-USD', 'sequence': 2311323872, 'user_id': '642394321fdf8242c4006432', 'profile_id': '039ee148-d490-44f9-9aed-0d1f6412884', 'time': '2018-07-07T17:33:29.755000Z'} 186 | . 187 | . 188 | . 189 | 190 | .. |Version| image:: https://img.shields.io/pypi/v/copra.svg 191 | :target: https://pypi.python.org/pypi/copra 192 | 193 | .. |Build Status| image:: https://img.shields.io/travis/tpodlaski/copra.svg 194 | :target: https://travis-ci.org/tpodlaski/copra 195 | 196 | .. |Docs| image:: https://readthedocs.org/projects/copra/badge/?version=latest 197 | :target: https://copra.readthedocs.io/en/latest/?badge=latest 198 | :alt: Documentation Status 199 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========================================= 2 | CoPrA 3 | ========================================= 4 | 5 | *Asyncronous Python REST and WebSocket Clients for Coinbase Pro* 6 | 7 | ----------------------------------------- 8 | 9 | | |Version| |Build Status| |Docs| 10 | 11 | | 12 | 13 | | **Quick Links**: `Documentation `__ - `Source Code `__ - `PyPi `__ 14 | 15 | | **Related**: `Coinbase Pro Digital Currency Exchange `__ - `Coinbase Pro REST API `_ - `Coinbase Pro WebSocket API `_ 16 | 17 | ----------------------------------------- 18 | 19 | Introduction 20 | ------------ 21 | 22 | The CoPrA \(**Co**\ inbase **Pr**\ o **A**\ sync\) package provides asyncronous REST and WebSocket clients written in Python for use with the Coinbase Pro digital currency trading platform. To learn about Coinbase Pro's REST and WebSocket APIs as well as how to obtain an API key for authentication to those services, please see `Coinbase Pro's API documentation `__. 23 | 24 | CoPrA Features 25 | -------------- 26 | 27 | * compatible with Python 3.5 or greater 28 | * utilizes Python's `asyncio `__ concurrency framework 29 | * open source (`MIT `__ license) 30 | 31 | REST Features 32 | +++++++++++++ 33 | 34 | * Asyncronous REST client class with 100% of the account management, trading, and market data functionality offered by the Coinbase Pro REST API. 35 | * supports user authentication 36 | * built on **aiohttp**, the asynchronous HTTP client/server framework for asyncio and Python 37 | 38 | WebSocket Features 39 | ++++++++++++++++++ 40 | 41 | * Asyncronous WebSocket client class with callback hooks for managing every phase of a Coinbase Pro WebSocket session 42 | * supports user authentication 43 | * built on **Autobahn|Python**, the open-source (MIT) real-time framework for web, mobile & the Internet of Things. 44 | 45 | 46 | Examples 47 | -------- 48 | 49 | REST 50 | ++++ 51 | Without a Coinbase Pro API key, ``copra.rest.Client`` has access to all of the public market data that Coinbase makes available. 52 | 53 | .. code:: python 54 | 55 | # 24hour_stats.py 56 | 57 | import asyncio 58 | 59 | from copra.rest import Client 60 | 61 | loop = asyncio.get_event_loop() 62 | 63 | client = Client(loop) 64 | 65 | async def get_stats(): 66 | btc_stats = await client.get_24hour_stats('BTC-USD') 67 | print(btc_stats) 68 | 69 | loop.run_until_complete(get_stats()) 70 | loop.run_until_complete(client.close()) 71 | 72 | Running the above: 73 | 74 | .. code:: bash 75 | 76 | $ python3 24hour_stats.py 77 | {'open': '3914.96000000', 'high': '3957.10000000', 'low': '3508.00000000', 'volume': '37134.10720409', 'last': '3670.06000000', 'volume_30day': '423047.53794129'} 78 | 79 | In conjunction with a Coinbase Pro API key, ``copra.rest.Client`` can be used to trade cryptocurrency and manage your Coinbase pro account. This example also shows how ``copra.rest.Client`` can be used as a context manager. 80 | 81 | .. code:: python 82 | 83 | # btc_account_info.py 84 | 85 | import asyncio 86 | 87 | from copra.rest import Client 88 | 89 | KEY = YOUR_API_KEY 90 | SECRET = YOUR_API_SECRET 91 | PASSPHRASE = YOUR_API_PASSPHRASE 92 | 93 | BTC_ACCOUNT_ID = YOUR_BTC_ACCOUNT_ID 94 | 95 | loop = asyncio.get_event_loop() 96 | 97 | async def get_btc_account(): 98 | async with Client(loop, auth=True, key=KEY, 99 | secret=SECRET, passphrase=PASSPHRASE) as client: 100 | 101 | btc_account = await client.account(BTC_ACCOUNT_ID) 102 | print(btc_account) 103 | 104 | loop.run_until_complete(get_btc_account()) 105 | 106 | Running the above: 107 | 108 | .. code:: bash 109 | 110 | $ python3 btc_account_info.py 111 | {'id': '1b121cbe-bd4-4c42-9e31-7047632fc7c7', 'currency': 'BTC', 'balance': '26.1023109600000000', 'available': '26.09731096', 'hold': '0.0050000000000000', 'profile_id': '151d9abd-abcc-4597-ae40-b6286d72a0bd'} 112 | 113 | WebSocket 114 | +++++++++ 115 | 116 | While ``copra.websocket.Client`` is meant to be overridden, but it can be used 'as is' to test the module through the command line. 117 | 118 | .. code:: python 119 | 120 | # btc_heartbeat.py 121 | 122 | import asyncio 123 | 124 | from copra.websocket import Channel, Client 125 | 126 | loop = asyncio.get_event_loop() 127 | 128 | ws = Client(loop, Channel('heartbeat', 'BTC-USD')) 129 | 130 | try: 131 | loop.run_forever() 132 | except KeyboardInterrupt: 133 | loop.run_until_complete(ws.close()) 134 | loop.close() 135 | 136 | Running the above: 137 | 138 | .. code:: bash 139 | 140 | $ python3 btc_heartbeat.py 141 | {'type': 'subscriptions', 'channels': [{'name': 'heartbeat', 'product_ids': ['BTC-USD']}]} 142 | {'type': 'heartbeat', 'last_trade_id': 45950713, 'product_id': 'BTC-USD', 'sequence': 6254273323, 'time': '2018-07-05T22:36:30.823000Z'} 143 | {'type': 'heartbeat', 'last_trade_id': 45950714, 'product_id': 'BTC-USD', 'sequence': 6254273420, 'time': '2018-07-05T22:36:31.823000Z'} 144 | {'type': 'heartbeat', 'last_trade_id': 45950715, 'product_id': 'BTC-USD', 'sequence': 6254273528, 'time': '2018-07-05T22:36:32.823000Z'} 145 | {'type': 'heartbeat', 'last_trade_id': 45950715, 'product_id': 'BTC-USD', 'sequence': 6254273641, 'time': '2018-07-05T22:36:33.823000Z'} 146 | {'type': 'heartbeat', 'last_trade_id': 45950715, 'product_id': 'BTC-USD', 'sequence': 6254273758, 'time': '2018-07-05T22:36:34.823000Z'} 147 | {'type': 'heartbeat', 'last_trade_id': 45950720, 'product_id': 'BTC-USD', 'sequence': 6254273910, 'time': '2018-07-05T22:36:35.824000Z'} 148 | . 149 | . 150 | . 151 | 152 | A Coinbase Pro API key allows ``copra.websocket.Client`` to authenticate with the Coinbase WebSocket server giving you access to feeds specific to your user account. 153 | 154 | .. code:: python 155 | 156 | # user_channel.py 157 | 158 | import asyncio 159 | 160 | from copra.websocket import Channel, Client 161 | 162 | KEY = YOUR_API_KEY 163 | SECRET = YOUR_API_SECRET 164 | PASSPHRASE = YOUR_API_PASSPHRASE 165 | 166 | loop = asyncio.get_event_loop() 167 | 168 | channel = Channel('user', 'LTC-USD') 169 | 170 | ws = Client(loop, channel, auth=True, key=KEY, secret=SECRET, passphrase=PASSPHRASE) 171 | 172 | try: 173 | loop.run_forever() 174 | except KeyboardInterrupt: 175 | loop.run_until_complete(ws.close()) 176 | loop.close() 177 | 178 | 179 | Running the above: 180 | 181 | .. code:: bash 182 | 183 | $ python3 user_channel.py 184 | {'type': 'subscriptions', 'channels': [{'name': 'user', 'product_ids': ['LTC-USD']}]} 185 | {'type': 'received', 'order_id': '42d2677d-0d37-435f-a776-e9e7f81ff22b', 'order_type': 'limit', 'size': '50.00000000', 'price': '1.00000000', 'side': 'buy', 'client_oid': '00098b59-4ac9-4ff8-ba16-bd2ef673f7b7', 'product_id': 'LTC-USD', 'sequence': 2311323871, 'user_id': '642394321fdf8242c4006432', 'profile_id': '039ee148-d490-44f9-9aed-0d1f6412884', 'time': '2018-07-07T17:33:29.755000Z'} 186 | {'type': 'open', 'side': 'buy', 'price': '1.00000000', 'order_id': '42d2677d-0d37-435f-a776-e9e7f81ff22b', 'remaining_size': '50.00000000', 'product_id': 'LTC-USD', 'sequence': 2311323872, 'user_id': '642394321fdf8242c4006432', 'profile_id': '039ee148-d490-44f9-9aed-0d1f6412884', 'time': '2018-07-07T17:33:29.755000Z'} 187 | . 188 | . 189 | . 190 | 191 | Versioning 192 | ---------- 193 | 194 | We use `SemVer `__ for versioning. For the versions available, see the `tags on this repository `__. 195 | 196 | 197 | License 198 | ------- 199 | 200 | This project is licensed under the **MIT License** - see the `LICENSE file `_ for details 201 | 202 | 203 | Authors 204 | ------- 205 | **Tony Podlaski** - http://www.neuraldump.net 206 | 207 | See also the list of `contributers `__ who participated in this project. 208 | 209 | Contributing 210 | ------------ 211 | Please read `CONTRIBUTING.rst `__ for details on our code of conduct, and the process for submitting pull requests to us. 212 | 213 | 214 | Credits 215 | ------- 216 | 217 | This package was created with `Cookiecutter `__ and the `audreyr/cookiecutter-pypackage `__ project template. 218 | 219 | 220 | .. |Version| image:: https://img.shields.io/pypi/v/copra.svg 221 | :target: https://pypi.python.org/pypi/copra 222 | 223 | .. |Build Status| image:: https://img.shields.io/travis/tpodlaski/copra.svg 224 | :target: https://travis-ci.org/tpodlaski/copra 225 | 226 | .. |Docs| image:: https://readthedocs.org/projects/copra/badge/?version=latest 227 | :target: https://copra.readthedocs.io/en/latest/?badge=latest 228 | :alt: Documentation Status 229 | -------------------------------------------------------------------------------- /docs/websocket/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | .. warning:: 6 | 7 | Any references made below to specific aspects of the Coinbase Pro API such as the channels and the data they provide may be out of date. Please visit `Coinbase Pro's WebSocket API documentation `__ for the authorative and up to date information. 8 | 9 | Introduction 10 | ------------ 11 | 12 | The CoPrA API provides two classes for creating a WebSocket client for the Coinbase Pro platform. The first, ``copra.websocket.Channel``, is intended to be used "as is." The second, ``copra.websocket.Client``, is the actual client class. It provides multiple callback methods to manage every stage of the client's life cycle. 13 | 14 | Channel 15 | ------- 16 | 17 | At the heart of every WebSocket connection is the concept of a channel. A channel provides a specific type of data 18 | about one or more currency pairs. ``copra.websocket.Channel`` has two attributes: it's name ``name`` and the product pairs the channel is observing, ``product_ids``. 19 | 20 | The current channels provided by the Coinbase Pro API are: 21 | 22 | * **heartbeart** - heartbeat messages are generated once a second. They include sequence numbers and last trade IDs that can be used to verify no messages were missed. 23 | 24 | * **ticker** - ticker messages are sent every time a match happens providing real-time price updates. 25 | 26 | * **level2** - level2 messages provide a high level view of the order book. After the initial snapshot of the order book is delivered, messages are sent every time the volume at specific price tier on the buy or sell side changes. 27 | 28 | * **full** - the full channel provides real-time updates on orders and trades. There are messages for every stage of an orders life cycle including: received, open, match, done, change, and activate. 29 | 30 | * **user** - the user channel provides the same information as the full channel but only for the authenticated user. As such you will need to be authenticated to susbsribe. This requires a Coinbase Pro API key. 31 | 32 | * **matches** - this channel consists only of the match messages from the full channel. 33 | 34 | The Coinbase Pro exchange currently hosts four digital currencies: 35 | 36 | * **BTC** - Bitcoin 37 | * **BCH** - Bitcoin Cash 38 | * **ETH** - Etherium 39 | * **LTC** - Litecoin Cash 40 | 41 | And allows 3 fiat currencies for trading: 42 | 43 | * **USD** - US Dollar 44 | * **EUR** - Euro 45 | * **GBP** - Great British Pounds (Sterling) 46 | 47 | Not every combination of currencies is available for trading, however. The current currency pairs (or products) avaialable for trade are: 48 | 49 | * **BTC-USD** 50 | * **BTC-EUR** 51 | * **BTC-GBP** 52 | * **ETH-USD** 53 | * **ETH-EUR** 54 | * **ETH-BTC** 55 | * **LTC-USD** 56 | * **LTC-EUR** 57 | * **LTC-BTC** 58 | * **BCH-USD** 59 | * **BCH-EUR** 60 | * **BCH-BTC** 61 | 62 | These are the product IDs referenced below. 63 | 64 | Before connecting to the Coinbase Pro Websocket server, you will need to create one or more channels to subscribe to. 65 | 66 | First, import the ``Channel`` class: 67 | 68 | .. code:: python 69 | 70 | from copra.websocket import Channel 71 | 72 | The channel is then initialized with its name and one or more product IDs. The heartbeat channel for the Bitcoin/US dollar pair would be initialized: 73 | 74 | .. code:: python 75 | 76 | channel = Channel('heartbeat', 'BTC-USD') 77 | 78 | A channel that recieves ticker information about the pairs Etherium/US dollar and Litecoin/Euro would be initialized: 79 | 80 | .. code:: python 81 | 82 | channel = Channel('ticker', ['ETH-USD', 'LTC-EUR']) 83 | 84 | As illustrated above, the product ID argument to the ``Channel`` constructor can be a single string or a list of strings. 85 | 86 | To listen for messages about Bitcoin/US Dollar and Litecoin/Bitcoin orders for an authenticated user: 87 | 88 | .. code:: python 89 | 90 | channel = Channel('user', ['BTC-USD', 'LTC-BTC']) 91 | 92 | As noted above, this will require that the ``Client`` be authenticated. This is covered below. 93 | 94 | Client 95 | ------ 96 | The ``Client`` class represents the Coinbase Pro WebSocket client. While it can be used "as is", most developers will want to subclass it in order to customize the behavior of its callback methods. 97 | 98 | First it needs to be imported: 99 | 100 | .. code:: python 101 | 102 | from copra.websocket import Client 103 | 104 | For reference, the signature of the ``Client`` ``__init__`` method is: 105 | 106 | .. code:: python 107 | 108 | def __init__(self, loop, channels, feed_url=FEED_URL, 109 | auth=False, key='', secret='', passphrase='', 110 | auto_connect=True, auto_reconnect=True, 111 | name='WebSocket Client') 112 | 113 | Only two parameters are required to create a client: ``loop`` and ``channels``. 114 | 115 | ``loop`` is the Python asyncio loop that the client will run in. Somewhere in your code you will likely have something like: 116 | 117 | .. code:: python 118 | 119 | import asyncio 120 | 121 | loop = asyncio.get_event_loop() 122 | 123 | ``channels`` is either a single ``Channel`` or a list of ``Channels`` the client should immediately subscribe to. 124 | 125 | ``feed_url`` is the url of the Coinbase Pro Websocket server. The default is ``copra.websocket.FEED_URL`` which is wss://ws-feed.pro.coinbase.com:443. 126 | 127 | If you want to test your code in Coinbase's "sandbox" development environment, you can set ``feed_url`` to ``copra.websocket.SANDBOX_FEED_URL`` which is wss://ws-feed-public.sandbox.pro.coinbase.com:443. 128 | 129 | ``auth`` indicates whether or not the client will be authenticated. If True, you will need to also provide ``key``, ``secret``, and ``passphrase``. These values are provided by Coinbase Pro when you register for an API key. 130 | 131 | ``auto_connect`` determines whether or not to automatically add the client to the asyncio loop. If true, the client will be added to the loop when it (the client) is initialized. If the loop is already running, the WebSocket connection will open. If the loop is not yet running, the connection will be made as soon as the loop is started. 132 | 133 | If ``auto_connect`` is False, you will need to explicitly call ``client.add_as_task_to_loop()`` when you are ready to add the client to the asyncio loop and open the WebSocket connection. 134 | 135 | ``auto_reconnect`` determines the client's behavior is the connection is closed in any way other than by explicitly calling its ``close`` method. If True, the client will automatically try to reconnect and re-subscribe to the channels it subscribed to when the connection unexpectedly closed. 136 | 137 | ``name`` is a simple string representing the name of the client. Setting this to something unique may be useful for logging purposes. 138 | 139 | Callback Methods 140 | ~~~~~~~~~~~~~~~~ 141 | 142 | The ``Client`` class provides four methods that are automatically called at different stages of the client's life cycle. The method that will be most useful for developers is ``on_message()``. 143 | 144 | on_open() 145 | ^^^^^^^^^ 146 | 147 | ``on_open`` is called as soon as the initial WebSocket opening handshake is complete. The connection is open, but the client is **not yet subscribed**. 148 | 149 | If you override this method it is important that **you still call it** from your subclass' ``on_open`` method, since the parent method sends the initial subscription request to the WebSocket server. Somewhere in your ``on_open`` method you should have ``super().on_open()``. 150 | 151 | In addition to sending the subsciption request, this method also logs that the connection was opened. 152 | 153 | on_message(message) 154 | ^^^^^^^^^^^^^^^^^^^ 155 | 156 | ``on_message`` is called everytime a message is received. ``message`` is a dict representing the message. Its content will depend on the type of message, the channels subscribed to, etc. Please read `Coinbase Pro's WebSocket API documentation `__ to learn about these message formats. 157 | 158 | Note that with the exception of errors, every other message triggers this method including things like subscription confirmations. Your code should be prepared to handle unexpected messages. 159 | 160 | This default method just prints the message received. If you override this method, there is no need to call the parent method from your subclass' method. 161 | 162 | on_error(message, reason) 163 | ^^^^^^^^^^^^^^^^^^^^^^^^ 164 | 165 | ``on_error`` is called when an error message is received from the WebSocket server. ``message`` a is string representing the error, and ``reason`` is a string that provides additional information about the cause of the error. Note that in many cases ``reason`` is blank. 166 | 167 | The default implementation just logs the message and reason. If you override this method, your subclass only needs to call the parent's method if want to preserve this logging behavior. 168 | 169 | on_close( was_clean, code, reason) 170 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 171 | 172 | ``on_close`` is called whenever the connection between the client and server is closed. ``was_clean`` is a boolean indicating whether or not the connection was cleanly closed. ``code``, an integer, and ``reason``, a string, are sent by the end that initiated closing the connection. 173 | 174 | If the client did not initiate this closure and ``client.auto_reconnect`` is set to True, the client will attempt to reconnect to the server and resubscribe to the channels it was subscribed to when the connection was closed. This method also logs the closure. 175 | 176 | If your subclass overrides this method, it is important that the subclass method calls the parent method if you want to preserve the auto reconnect functionality. This can be done by including ``super().on_close(was_clean, code, reason)`` in your subclass method. 177 | 178 | Other Methods 179 | ~~~~~~~~~~~~~ 180 | 181 | close() 182 | ^^^^^^^ 183 | 184 | ``close`` is called to close the connection to the WebSocket server. Note that if you call this method, the client will not attempt to auto reconnect regardless of what the value of ``client.auto_reconnect`` is. 185 | 186 | subscribe(channels) 187 | ^^^^^^^^^^^^^^^^^^^ 188 | 189 | `subscribe` is called to susbcribe to additional channels. ``channels`` is either a single Channel or a list of Channels. 190 | 191 | The original channels to be subscribed to are defined during the client's initialization. ``subscribe`` can be used to add channels whether the client has been added to asyncio loop yet or not. If the loop isn't yet running, the client will subscribe to all of its channels when it is. If the loop is already running, the subcription will be appended with new channels, and incoming data will be immediately received. 192 | 193 | unsubscribe(channels) 194 | ^^^^^^^^^^^^^^^^^^^^^ 195 | 196 | ``unsubscribe`` is called to unsubscribe from channels. ``channels`` is either a single Channel or a list of Channels. 197 | 198 | Like ``subscribe``, ``unsubscribe`` can be called regardless of whether or not the client has already been added to the asyncio loop. If the client has not yet been added, ``unsubscribe`` will remove those channels from the set of channels to be initially subscribed to. If the client has already been added to the loop, ``unsubscribe`` will remove those channels from the subscription, and data flow from them will stop immediately. 199 | -------------------------------------------------------------------------------- /copra/websocket/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Asynchronous WebSocket client for the Coinbase Pro platform. 3 | 4 | """ 5 | 6 | import asyncio 7 | import base64 8 | import hashlib 9 | import hmac 10 | import json 11 | import logging 12 | import time 13 | from urllib.parse import urlparse 14 | 15 | from autobahn.asyncio.websocket import WebSocketClientFactory 16 | from autobahn.asyncio.websocket import WebSocketClientProtocol 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | FEED_URL = 'wss://ws-feed.pro.coinbase.com:443' 21 | SANDBOX_FEED_URL = 'wss://ws-feed-public.sandbox.pro.coinbase.com:443' 22 | 23 | 24 | class ClientProtocol(WebSocketClientProtocol): 25 | """Websocket client protocol. 26 | 27 | This is a subclass of autobahn.asyncio.WebSocket.WebSocketClientProtocol. 28 | In most cases this should not need to be subclassed or even accessed 29 | directly. 30 | """ 31 | 32 | def __call__(self): 33 | return self 34 | 35 | def onOpen(self): 36 | """Callback fired on initial WebSocket opening handshake completion. 37 | 38 | You now can send and receive WebSocket messages. 39 | """ 40 | self.factory.on_open() 41 | 42 | def onClose(self, wasClean, code, reason): 43 | """Callback fired when the WebSocket connection has been closed. 44 | 45 | (WebSocket closing handshake has been finished or the connection was 46 | closed uncleanly). 47 | 48 | Args: 49 | wasClean (bool): True iff the WebSocket connection closed cleanly. 50 | code (int or None): Close status code as sent by the WebSocket peer. 51 | reason (str or None): Close reason as sent by the WebSocket peer. 52 | """ 53 | self.factory.on_close(wasClean, code, reason) 54 | 55 | def onMessage(self, payload, isBinary): 56 | """Callback fired when a complete WebSocket message was received. 57 | 58 | Call its factory's (the client's) on_message method with a 59 | dict representing the JSON message receieved. 60 | 61 | Args: 62 | payload (bytes): The WebSocket message received. 63 | isBinary (bool): Flag indicating whether payload is binary or UTF-8 64 | encoded text. 65 | """ 66 | msg = json.loads(payload.decode('utf8')) 67 | if msg['type'] == 'error': 68 | self.factory.on_error(msg['message'], msg.get('reason', '')) 69 | else: 70 | self.factory.on_message(msg) 71 | 72 | 73 | class Client(WebSocketClientFactory): 74 | """Asyncronous WebSocket client for Coinbase Pro. 75 | """ 76 | 77 | def __init__(self, loop, channels, feed_url=FEED_URL, 78 | auth=False, key='', secret='', passphrase='', 79 | auto_connect=True, auto_reconnect=True, 80 | name='WebSocket Client'): 81 | """ 82 | 83 | :param loop: The asyncio loop that the client runs in. 84 | :type loop: asyncio loop 85 | 86 | :param channels: The channels to initially subscribe to. 87 | :type channels: Channel or list of Channels 88 | 89 | :param str feed_url: The url of the WebSocket server. The defualt is 90 | copra.WebSocket.FEED_URL (wss://ws-feed.gdax.com) 91 | 92 | :param bool auth: Whether or not the (entire) WebSocket session is 93 | authenticated. If True, you will need an API key from the 94 | Coinbase Pro website. The default is False. 95 | 96 | :param str key: The API key to use for authentication. Required if auth 97 | is True. The default is ''. 98 | 99 | :param str secret: The secret string for the API key used for 100 | authenticaiton. Required if auth is True. The default is ''. 101 | 102 | :param str passphrase: The passphrase for the API key used for 103 | authentication. Required if auth is True. The default is ''. 104 | 105 | :param bool auto_connect: If True, the Client will automatically add 106 | itself to its event loop (ie., open a connection if the loop is 107 | running or as soon as it starts). If False, add_as_task_to_loop() 108 | needs to be explicitly called to add the client to the loop. The 109 | default is True. 110 | 111 | :param bool auto_reconnect: If True, the Client will attemp to autom- 112 | matically reconnect and resubscribe if the connection is closed any 113 | way but by the Client explicitly itself. The default is True. 114 | 115 | :param str name: A name to identify this client in logging, etc. 116 | 117 | :raises ValueError: If auth is True and key, secret, and passphrase are 118 | not provided. 119 | """ 120 | 121 | self.loop = loop 122 | 123 | self.connected = asyncio.Event() 124 | self.disconnected = asyncio.Event() 125 | self.disconnected.set() 126 | self.closing = False 127 | 128 | if not isinstance(channels, list): 129 | channels = [channels] 130 | 131 | self._initial_channels = channels 132 | self.feed_url = feed_url 133 | 134 | self.channels = {} 135 | self.subscribe(channels) 136 | 137 | if auth and not (key and secret and passphrase): 138 | raise ValueError('auth requires key, secret, and passphrase') 139 | 140 | self.auth = auth 141 | self.key = key 142 | self.secret = secret 143 | self.passphrase = passphrase 144 | 145 | self.auto_connect = auto_connect 146 | self.auto_reconnect = auto_reconnect 147 | self.name = name 148 | 149 | super().__init__(self.feed_url) 150 | 151 | if self.auto_connect: 152 | self.add_as_task_to_loop() 153 | 154 | def _get_subscribe_message(self, channels, unsubscribe=False, timestamp=None): 155 | """Create and return the subscription message for the provided channels. 156 | 157 | :param channels: List of channels to be subscribed to. 158 | :type channels: list of Channel 159 | 160 | :param bool unsubscribe: If True, returns an unsubscribe message 161 | instead of a subscribe method. The default is False. 162 | 163 | :returns: JSON-formatted, UTF-8 encoded bytes object representing the 164 | subscription message for the provided channels. 165 | """ 166 | msg_type = 'unsubscribe' if unsubscribe else 'subscribe' 167 | msg = {'type': msg_type, 168 | 'channels': [channel._as_dict() for channel in channels]} 169 | 170 | if self.auth: 171 | if not timestamp: 172 | timestamp = str(time.time()) 173 | message = timestamp + 'GET' + '/users/self/verify' 174 | message = message.encode('ascii') 175 | hmac_key = base64.b64decode(self.secret) 176 | signature = hmac.new(hmac_key, message, hashlib.sha256) 177 | signature_b64 = base64.b64encode(signature.digest()) 178 | signature_b64 = signature_b64.decode('utf-8').rstrip('\n') 179 | 180 | msg['signature'] = signature_b64 181 | msg['key'] = self.key 182 | msg['passphrase'] = self.passphrase 183 | msg['timestamp'] = timestamp 184 | 185 | return json.dumps(msg).encode('utf8') 186 | 187 | def subscribe(self, channels): 188 | """Subscribe to the given channels. 189 | 190 | :param channels: The channels to subscribe to. 191 | :type channels: Channel or list of Channels 192 | """ 193 | if not isinstance(channels, list): 194 | channels = [channels] 195 | 196 | sub_channels = [] 197 | 198 | for channel in channels: 199 | if channel.name in self.channels: 200 | sub_channel = channel - self.channels[channel.name] 201 | if sub_channel: 202 | self.channels[channel.name] += channel 203 | sub_channels.append(sub_channel) 204 | 205 | else: 206 | self.channels[channel.name] = channel 207 | sub_channels.append(channel) 208 | 209 | if self.connected.is_set(): 210 | msg = self._get_subscribe_message(sub_channels) 211 | self.protocol.sendMessage(msg) 212 | 213 | def unsubscribe(self, channels): 214 | """Unsubscribe from the given channels. 215 | 216 | :param channels: The channels to subscribe to. 217 | :type channels: Channel or list of Channels 218 | """ 219 | if not isinstance(channels, list): 220 | channels = [channels] 221 | 222 | for channel in channels: 223 | if channel.name in self.channels: 224 | self.channels[channel.name] -= channel 225 | if not self.channels[channel.name]: 226 | del self.channels[channel.name] 227 | 228 | if self.connected.is_set(): 229 | msg = self._get_subscribe_message(channels, unsubscribe=True) 230 | self.protocol.sendMessage(msg) 231 | 232 | def add_as_task_to_loop(self): 233 | """Add the client to the asyncio loop. 234 | 235 | Creates a coroutine for making a connection to the WebSocket server and 236 | adds it as a task to the asyncio loop. 237 | """ 238 | self.protocol = ClientProtocol() 239 | url = urlparse(self.url) 240 | self.coro = self.loop.create_connection(self, url.hostname, url.port, 241 | ssl=(url.scheme == 'wss')) 242 | self.loop.create_task(self.coro) 243 | 244 | def on_open(self): 245 | """Callback fired on initial WebSocket opening handshake completion. 246 | 247 | The WebSocket is open. This method sends the subscription message to 248 | the server. 249 | """ 250 | self.connected.set() 251 | self.disconnected.clear() 252 | self.closing = False 253 | logger.info('{} connected to {}'.format(self.name, self.url)) 254 | msg = self._get_subscribe_message(self.channels.values()) 255 | self.protocol.sendMessage(msg) 256 | 257 | def on_close(self, was_clean, code, reason): 258 | """Callback fired when the WebSocket connection has been closed. 259 | 260 | (WebSocket closing handshake has been finished or the connection was 261 | closed uncleanly). 262 | 263 | :param bool was_clean: True iff the WebSocket connection closed cleanly. 264 | 265 | :param code: Close status code as sent by the WebSocket peer. 266 | :type code: int or None 267 | 268 | :param reason: Close reason as sent by the WebSocket peer. 269 | :type reason: str or None 270 | """ 271 | self.connected.clear() 272 | self.disconnected.set() 273 | 274 | msg = '{} connection to {} {}closed. {}' 275 | expected = 'unexpectedly ' if self.closing is False else '' 276 | 277 | logger.info(msg.format(self.name, self.url, expected, reason)) 278 | 279 | if not self.closing and self.auto_reconnect: 280 | msg = '{} attempting to reconnect to {}.' 281 | logger.info(msg.format(self.name, self.url)) 282 | 283 | self.add_as_task_to_loop() 284 | 285 | def on_error(self, message, reason=''): 286 | """Callback fired when an error message is received. 287 | 288 | :param str message: A general description of the error. 289 | :param str reason: A more detailed description of the error. 290 | 291 | """ 292 | logger.error('{}. {}'.format(message, reason)) 293 | 294 | def on_message(self, message): 295 | """Callback fired when a complete WebSocket message was received. 296 | 297 | You will likely want to override this method. 298 | 299 | :param dict message: Dictionary representing the message. 300 | """ 301 | print(message) 302 | 303 | async def close(self): 304 | """Close the WebSocket connection. 305 | """ 306 | self.closing = True 307 | self.protocol.sendClose() 308 | await self.disconnected.wait() 309 | 310 | if __name__ == '__main__': 311 | # A sanity check. 312 | 313 | logging.getLogger().setLevel(logging.DEBUG) 314 | logging.getLogger().addHandler(logging.StreamHandler()) 315 | 316 | loop = asyncio.get_event_loop() 317 | 318 | ws = Client(loop, [Channel('heartbeat', 'BTC-USD')]) 319 | 320 | async def add_a_channel(): 321 | await asyncio.sleep(20) 322 | ws.subscribe(Channel('heartbeat', 'LTC-USD')) 323 | loop.create_task(remove_a_channel()) 324 | 325 | async def remove_a_channel(): 326 | await asyncio.sleep(20) 327 | ws.unsubscribe(Channel('heartbeat', 'BTC-USD')) 328 | 329 | loop.create_task(add_a_channel()) 330 | 331 | try: 332 | loop.run_forever() 333 | except KeyboardInterrupt: 334 | loop.run_until_complete(ws.close()) 335 | loop.close() 336 | -------------------------------------------------------------------------------- /tests/unit/websocket/test_client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """Tests for `copra.websocket` module.""" 5 | 6 | import asyncio 7 | import json 8 | import sys 9 | from urllib.parse import urlparse 10 | 11 | from asynctest import TestCase, patch, CoroutineMock, MagicMock, skipUnless 12 | 13 | from copra.websocket import Channel, Client, FEED_URL, SANDBOX_FEED_URL 14 | from copra.websocket.client import ClientProtocol 15 | 16 | # These are made up 17 | TEST_KEY = 'a035b37f42394a6d343231f7f772b99d' 18 | TEST_SECRET = 'aVGe54dHHYUSudB3sJdcQx4BfQ6K5oVdcYv4eRtDN6fBHEQf5Go6BACew4G0iFjfLKJHmWY5ZEwlqxdslop4CC==' 19 | TEST_PASSPHRASE = 'a2f9ee4dx2b' 20 | 21 | 22 | class TestClientProtocol(TestCase): 23 | """Tests for cbprotk.websocket.ClientProtocol""" 24 | 25 | def setUp(self): 26 | self.protocol = ClientProtocol() 27 | self.protocol.factory = MagicMock() 28 | 29 | def tearDown(self): 30 | """Tear down test fixtures, if any.""" 31 | 32 | def test___call__(self): 33 | self.assertIs(self.protocol(), self.protocol) 34 | 35 | @skipUnless(sys.version_info >= (3, 6), 'MagicMock.assert_called_once not implemented.') 36 | def test_onOpen(self): 37 | self.protocol.onOpen() 38 | self.protocol.factory.on_open.assert_called_once() 39 | 40 | def test_onClose(self): 41 | self.protocol.onClose(True, 200, 'OK') 42 | self.protocol.factory.on_close.assert_called_with(True, 200, 'OK') 43 | 44 | def test_onMessage(self): 45 | msg_dict = {'type': 'test', 'another_key': 200} 46 | msg = json.dumps(msg_dict).encode('utf8') 47 | self.protocol.onMessage(msg, True) 48 | self.protocol.factory.on_message.assert_called_with(msg_dict) 49 | 50 | msg_dict = {'type': 'error', 'message': 404, 'reason': 'testing'} 51 | msg = json.dumps(msg_dict).encode('utf8') 52 | self.protocol.onMessage(msg, True) 53 | self.protocol.factory.on_error.called_with(404, 'testing') 54 | 55 | 56 | class TestClient(TestCase): 57 | """Tests for copra.websocket.client.Client""" 58 | 59 | def setUp(self): 60 | pass 61 | 62 | def tearDown(self): 63 | pass 64 | 65 | def test__init__(self): 66 | channel1 = Channel('heartbeat', ['BTC-USD', 'LTC-USD']) 67 | channel2 = Channel('level2', ['LTC-USD']) 68 | 69 | client = Client(self.loop, channel1, auto_connect=False) 70 | self.assertEqual(client._initial_channels, [channel1]) 71 | self.assertEqual(client.feed_url, 'wss://ws-feed.pro.coinbase.com:443') 72 | 73 | client = Client(self.loop, channel1, SANDBOX_FEED_URL, auto_connect=False) 74 | self.assertEqual(client.feed_url, SANDBOX_FEED_URL) 75 | 76 | client = Client(self.loop, channel1, auto_connect=False) 77 | self.assertEqual(client._initial_channels, [channel1]) 78 | self.assertEqual(client.channels, {channel1.name: channel1}) 79 | 80 | client = Client(self.loop, [channel1], auto_connect=False) 81 | self.assertEqual(client._initial_channels, [channel1]) 82 | self.assertEqual(client.channels, {channel1.name: channel1}) 83 | 84 | client = Client(self.loop, [channel1, channel2], auto_connect=False) 85 | self.assertEqual(client._initial_channels, [channel1, channel2]) 86 | self.assertEqual(client.channels, 87 | {channel1.name: channel1, channel2.name: channel2}) 88 | 89 | client = Client(self.loop, [channel1, channel2], name="Test", auto_connect=False) 90 | self.assertEqual(client.name, "Test") 91 | 92 | #auth, no key, secret, or passphrase 93 | with self.assertRaises(ValueError): 94 | client = Client(self.loop, channel1, auth=True, auto_connect=False) 95 | 96 | #auth, key, no secret or passphrase 97 | with self.assertRaises(ValueError): 98 | client = Client(self.loop, channel1, auth=True, key='MyKey', auto_connect=False) 99 | 100 | #auth, key, secret, no passphrase 101 | with self.assertRaises(ValueError): 102 | client = Client(self.loop, channel1, auth=True, key='MyKey', 103 | secret='MySecret', auto_connect=False) 104 | 105 | #auth, secret, no key or passphrase 106 | with self.assertRaises(ValueError): 107 | client = Client(self.loop, channel1, auth=True, secret='MySecret', auto_connect=False) 108 | 109 | #auth, secret, passphrase, no key 110 | with self.assertRaises(ValueError): 111 | client = Client(self.loop, channel1, auth=True, secret='MySecret', 112 | passphrase='MyPassphrase', auto_connect=False) 113 | 114 | #auth, passphrase, no key or secret 115 | with self.assertRaises(ValueError): 116 | client = Client(self.loop, channel1, auth=True, 117 | passphrase='MyPassphrase', auto_connect=False) 118 | 119 | #auth, key, secret, passphrase 120 | client = Client(self.loop, channel1, auth=True, key=TEST_KEY, 121 | secret=TEST_SECRET, passphrase=TEST_PASSPHRASE, 122 | auto_connect=False, auto_reconnect=False) 123 | self.assertEqual(client.loop, self.loop) 124 | self.assertEqual(client._initial_channels, [channel1]) 125 | self.assertEqual(client.feed_url, FEED_URL) 126 | self.assertEqual(client.channels, {channel1.name: channel1}) 127 | self.assertTrue(client.auth) 128 | self.assertEqual(client.key, TEST_KEY) 129 | self.assertEqual(client.secret, TEST_SECRET) 130 | self.assertEqual(client.passphrase, TEST_PASSPHRASE) 131 | self.assertFalse(client.auto_connect) 132 | self.assertFalse(client.auto_reconnect) 133 | self.assertEqual(client.name, 'WebSocket Client') 134 | self.assertFalse(client.connected.is_set()) 135 | self.assertTrue(client.disconnected.is_set()) 136 | self.assertFalse(client.closing) 137 | 138 | @skipUnless(sys.version_info >= (3, 6), 'MagicMock.assert_called_once not implemented.') 139 | def test__init__auto_connect(self): 140 | channel1 = Channel('heartbeat', ['BTC-USD', 'LTC-USD']) 141 | 142 | #noauth, auto_connect, name 143 | with patch('copra.websocket.client.Client.add_as_task_to_loop') as mock_attl: 144 | client = Client(self.loop, channel1, name="Custom Name") 145 | self.assertEqual(client.loop, self.loop) 146 | self.assertEqual(client._initial_channels, [channel1]) 147 | self.assertEqual(client.feed_url, FEED_URL) 148 | self.assertEqual(client.channels, {channel1.name: channel1}) 149 | self.assertFalse(client.auth) 150 | self.assertTrue(client.auto_connect) 151 | self.assertTrue(client.auto_reconnect) 152 | self.assertEqual(client.name, 'Custom Name') 153 | self.assertFalse(client.connected.is_set()) 154 | self.assertTrue(client.disconnected.is_set()) 155 | self.assertFalse(client.closing) 156 | mock_attl.assert_called_once() 157 | 158 | 159 | def test__get_subscribe_message(self): 160 | channel1 = Channel('heartbeat', ['BTC-USD', 'LTC-USD']) 161 | channel2 = Channel('level2', ['LTC-USD']) 162 | 163 | client = Client(self.loop, [channel1, channel2], auto_connect=False) 164 | #subscribe 165 | msg = json.loads(client._get_subscribe_message(client.channels.values()).decode('utf8')) 166 | self.assertEqual(len(msg), 2) 167 | self.assertEqual(msg['type'], 'subscribe') 168 | self.assertIn(channel1._as_dict(), msg['channels']) 169 | self.assertIn(channel2._as_dict(), msg['channels']) 170 | #unsubscribe 171 | msg = json.loads(client._get_subscribe_message([channel1], unsubscribe=True).decode('utf8')) 172 | self.assertEqual(len(msg), 2) 173 | self.assertEqual(msg['type'], 'unsubscribe') 174 | self.assertIn(channel1._as_dict(), msg['channels']) 175 | self.assertFalse(channel2._as_dict() in msg['channels']) 176 | 177 | #authorized 178 | client = Client(self.loop, channel1, auth=True, key=TEST_KEY, 179 | secret=TEST_SECRET, passphrase=TEST_PASSPHRASE, 180 | auto_connect=False, auto_reconnect=False) 181 | 182 | msg = json.loads(client._get_subscribe_message(client.channels.values(), 183 | timestamp='1546384260.0321212').decode('utf8')) 184 | self.assertEqual(len(msg), 6) 185 | self.assertEqual(msg['type'], 'subscribe') 186 | self.assertIn(channel1._as_dict(), msg['channels']) 187 | self.assertEqual(msg['key'], TEST_KEY) 188 | self.assertEqual(msg['passphrase'], TEST_PASSPHRASE) 189 | self.assertEqual(msg['timestamp'], '1546384260.0321212') 190 | self.assertEqual(msg['signature'], 'KQq/poDCHjDDRURkQOc+QZi16c6cio9Yo/nF1+kts84=') 191 | 192 | 193 | def test_subscribe(self): 194 | channel1 = Channel('heartbeat', ['BTC-USD', 'LTC-USD']) 195 | channel2 = Channel('level2', ['LTC-USD']) 196 | channel3 = Channel('heartbeat', ['BTC-USD', 'BTC-EUR']) 197 | channel4 = Channel('heartbeat', ['ETH-USD']) 198 | 199 | client = Client(self.loop, [channel1], auto_connect=False) 200 | 201 | self.assertIn(channel1.name, client.channels) 202 | self.assertEqual(client.channels[channel1.name], channel1) 203 | 204 | client.subscribe(channel2) 205 | 206 | self.assertIn(channel2.name, client.channels) 207 | self.assertEqual(client.channels[channel2.name], channel2) 208 | 209 | client.subscribe(channel3) 210 | 211 | self.assertIn(channel3.name, client.channels) 212 | self.assertEqual(client.channels[channel3.name], channel1 + channel3) 213 | 214 | client.protocol.sendMessage = MagicMock() 215 | client.connected.set() 216 | 217 | client.subscribe(channel4) 218 | 219 | msg = client._get_subscribe_message([channel4]) 220 | client.protocol.sendMessage.assert_called_with(msg) 221 | 222 | 223 | def test_unsubscribe(self): 224 | channel1 = Channel('heartbeat', ['BTC-USD', 'LTC-USD', 'LTC-EUR']) 225 | channel2 = Channel('level2', ['LTC-USD']) 226 | channel3 = Channel('heartbeat', ['LTC-EUR']) 227 | channel4 = Channel('heartbeat', ['BTC-USD', 'BTC-EUR']) 228 | channel5 = Channel('heartbeat', ['BCH-USD', 'LTC-USD']) 229 | 230 | client = Client(self.loop, [channel1, channel2], auto_connect=False) 231 | 232 | client.unsubscribe(channel3) 233 | 234 | self.assertIn(channel3.name, client.channels) 235 | self.assertEqual(client.channels[channel3.name], Channel('heartbeat', ['BTC-USD', 'LTC-USD'])) 236 | 237 | client.unsubscribe(channel4) 238 | 239 | self.assertIn(channel4.name, client.channels) 240 | self.assertEqual(client.channels[channel4.name], Channel('heartbeat', ['LTC-USD'])) 241 | 242 | client.unsubscribe(channel3) 243 | self.assertIn(channel3.name, client.channels) 244 | self.assertEqual(client.channels[channel3.name], Channel('heartbeat', ['LTC-USD'])) 245 | 246 | client.unsubscribe(channel2) 247 | self.assertNotIn(channel2.name, client.channels) 248 | 249 | client.protocol.sendMessage = MagicMock() 250 | client.connected.set() 251 | 252 | client.unsubscribe(channel5) 253 | self.assertEqual(client.channels, {}) 254 | msg = client._get_subscribe_message([channel5], unsubscribe=True) 255 | client.protocol.sendMessage.assert_called_with(msg) 256 | 257 | 258 | def test_add_as_task_to_loop(self): 259 | channel1 = Channel('heartbeat', ['BTC-USD', 'LTC-USD']) 260 | client = Client(self.loop, channel1, auto_connect=False) 261 | 262 | client.loop.create_connection = CoroutineMock(return_value=CoroutineMock()) 263 | client.add_as_task_to_loop() 264 | 265 | url = urlparse(FEED_URL) 266 | client.loop.create_connection.assert_called_with(client, url.hostname, url.port, ssl=True) 267 | 268 | 269 | def test_on_open(self): 270 | channel1 = Channel('heartbeat', ['BTC-USD', 'LTC-USD', 'LTC-EUR']) 271 | channel2 = Channel('level2', ['LTC-USD']) 272 | client = Client(self.loop, [channel1, channel2], auto_connect=False) 273 | client.protocol.sendMessage = MagicMock() 274 | self.assertFalse(client.connected.is_set()) 275 | self.assertTrue(client.disconnected.is_set()) 276 | self.assertFalse(client.closing) 277 | 278 | msg = client._get_subscribe_message(client.channels.values()) 279 | client.on_open() 280 | 281 | self.assertTrue(client.connected.is_set()) 282 | self.assertFalse(client.disconnected.is_set()) 283 | self.assertFalse(client.closing) 284 | client.protocol.sendMessage.assert_called_with(msg) 285 | 286 | 287 | def test_on_close(self): 288 | channel1 = Channel('heartbeat', ['BTC-USD', 'LTC-USD', 'LTC-EUR']) 289 | client = Client(self.loop, [channel1], auto_connect=False) 290 | client.add_as_task_to_loop = MagicMock() 291 | client.connected.set() 292 | client.disconnected.clear() 293 | client.closing = True 294 | 295 | client.on_close(True, None, None) 296 | self.assertFalse(client.connected.is_set()) 297 | self.assertTrue(client.disconnected.is_set()) 298 | self.assertTrue(client.closing) 299 | client.add_as_task_to_loop.assert_not_called() 300 | 301 | @skipUnless(sys.version_info >= (3, 6), 'MagicMock.assert_called_once not implemented.') 302 | def test_on_close_unexpected(self): 303 | channel1 = Channel('heartbeat', ['BTC-USD', 'LTC-USD', 'LTC-EUR']) 304 | client = Client(self.loop, [channel1], auto_connect=False) 305 | client.add_as_task_to_loop = MagicMock() 306 | 307 | client.connected.set() 308 | client.disconnected.clear() 309 | client.closing = False 310 | 311 | client.on_close(True, None, None) 312 | self.assertFalse(client.connected.is_set()) 313 | self.assertTrue(client.disconnected.is_set()) 314 | self.assertFalse(client.closing) 315 | client.add_as_task_to_loop.assert_called_once() 316 | 317 | 318 | @skipUnless(sys.version_info >= (3, 6), 'MagicMock.assert_called_once not implemented. ') 319 | async def test_close(self): 320 | channel1 = Channel('heartbeat', ['BTC-USD', 'LTC-USD', 'LTC-EUR']) 321 | client = Client(self.loop, [channel1], auto_connect=False) 322 | client.protocol.sendClose = MagicMock(side_effect=lambda: client.disconnected.set()) 323 | client.disconnected.clear() 324 | self.assertFalse(client.closing) 325 | 326 | await client.close() 327 | self.assertTrue(client.closing) 328 | client.protocol.sendClose.assert_called_once() 329 | 330 | -------------------------------------------------------------------------------- /docs/rest/usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | .. warning:: 6 | 7 | Any references made below to specific aspects of the Coinbase Pro API such as the data structures returned by methods may be out of date. Please visit `Coinbase Pro's WebSocket REST API documentation `__ for the authorative and up to date API information. 8 | 9 | Introduction 10 | ------------ 11 | :class:`copra.rest.Client`, the asyncronous REST client class provided by CoPrA, is intentionally simple. Its methods were designed specifically to replicate all of the endpoints offered by the Coinbase Pro REST service, both in the parameters they expect and the data they return. 12 | 13 | With very few exceptions there is a one to one correspondence between :class:`copra.rest.Client` methods and the Coinbase endpoints. As often as possible, parameter names were kept the same and the json-encoded lists and dicts returned by the API server are, in turn, returned by the client methods untouched. This makes it simple to cross reference the CoPrA source code and documentation with `Coinbase's API documentation `_. 14 | 15 | Additionally, it should be relatively easy to extend the client in order to build finer grained ordering methods, sophisticated account management systems, and powerful market analytics tools. 16 | 17 | Errors 18 | ------ 19 | While :class:`copra.rest.Client` takes a hands-off approach to the data returned from API server, it does involve itself while preparing the user-supplied method paramters that will become part of the REST request. Specifically, in instances where the client can identify that the provided parameters will return an error from the API server, it raises a descriptive :class:`ValueError` in order to avoid an unnecessary server call. 20 | 21 | For example, the client method :meth:`copra.rest.Client.market_order` has parameters for the amount of currency to purchase or sell, ``size``, and for the amount of quote currency to use for the transaction, ``funds``. For a market order, it is impossible to require both so if both are sent in the method call, the client raises a :class:`ValueError`. 22 | 23 | The :class:`copra.rest.Client` API documentation details for each method in what instances :class:`ValueErrors` are raised. 24 | 25 | copra.rest.APIRequestError 26 | ++++++++++++++++++++++++++ 27 | 28 | On the other hand, there will be times the client cannot tell ahead of time that an API request will return an error. Insufficient funds, invalid account ids, improper authorization, and internal server errors are just a few examples of the errors a request may return. 29 | 30 | Because there are many potential error types, and the Coinbase documentation does not list them all, the :class:`copra.rest.Client` raises a generic error, :class:`copra.rest.APIRequestError`, whenever the HTTP status code of an API server response is non-2xx. 31 | 32 | The string representation of an ``APIRequestError`` is the message returned by the Coinbase server along the HTTP status code. ``APIRequestError``, also has an additional field, ``response``, is the `aiohttp.ClientResponse `_ object returned to the CoPrA client by the aiohttp request. This can be used to get more information about the request/response including full headers, etc. See the `aiohttp.ClientResponse documentation `_ to learn more about its attributes. 33 | 34 | To get a feel for the types of errors that the Coinbase server may return, please see the `Coinbase Pro API error documentation `_. 35 | 36 | REST Client 37 | ----------- 38 | 39 | The CoPrA REST client methods like their respective Coinbase REST API endpoints fall in one of two main categories: public and private. Public methods require no authentication. They offer access to market data and other publically avaiable information. The private methods *do* require authentication, specifically by means of an API key. To learn how to create an API key see the Coinbase Pro support article titled `"How do I create an API key for Coinbase Pro?" `_. 40 | 41 | Initialization 42 | ++++++++++++++ 43 | 44 | ``__init__(loop, url=URL, auth=False, key='', secret='', passphrase='')`` [:meth:`API Documentation `] 45 | 46 | Initialization of an unauthorized client only requires one parameter: the asyncio loop the client will be running in: 47 | 48 | .. code:: python 49 | 50 | import asyncio 51 | 52 | from copra.rest import Client 53 | 54 | loop = asyncio.get_event_loop() 55 | 56 | client = Client(loop) 57 | 58 | To initialize an authorized client you will also need the key, secret, passphrase that Coinbase provides you when you request an API key: 59 | 60 | 61 | .. code:: python 62 | 63 | import asyncio 64 | 65 | from copra.rest import Client 66 | 67 | loop = asyncio.get_event_loop() 68 | 69 | client = Client(loop, auth=True, key=YOUR_KEY, 70 | secret=YOUR_SECRET, passphrase=YOUR_PASSPHRASE) 71 | 72 | Client Lifecycle 73 | --------------- 74 | 75 | The lifecycle of a long-lived client is straight forward: 76 | 77 | .. code:: python 78 | 79 | client = Client(loop) 80 | 81 | # Make a fortune trading Bitcoin here 82 | 83 | await client.close() 84 | 85 | Initialize the client, make as many requests as you need to, and then close the client to release any resources the underlying aiohttp session acquired. Note that the Python interpreter will complain if your program closes without closing your client first. 86 | 87 | If you need to close the client from a function that is not a coroutine and the loop is remaining open you can close it like so: 88 | 89 | .. code:: python 90 | 91 | loop.create_task(client.close()) 92 | 93 | Or, if the loop is closing, use: 94 | 95 | .. code:: python 96 | 97 | loop.run_until_complete(client.close()) 98 | 99 | Context Manager 100 | --------------- 101 | 102 | If you only need to create a client, use it briefly and not need it again for the duraction of your program, you can create it as context manager in which case the client is closed automatically when program execution leave the context manager block: 103 | 104 | .. code:: python 105 | 106 | async with Client(loop) as client: 107 | client.do_something() 108 | client.do_something_else() 109 | 110 | Note that if you will be using the client repeatedly over the duration of your program, it is best to create one client, store a reference to it, and use it repeatedly instead of creating a new client every time you need to make a request or two. This has to do with the aiohttp session handles its connection pool. Connections are reused and keep-alives are on which will result in better performance in subsequent requests versus creating a new client every time. 111 | 112 | Public (Unauthenticated) Client Methods 113 | -------------- 114 | 115 | Coinbase refers to the collection of endpoints that do not require authorization as their "Market Data API". They further group those endpoints into 3 categories: products, currency and time. The CoPra rest client provides methods that are a one-to-one match to the endpoints in Coinbase's Market Data API. 116 | 117 | Products 118 | ++++++++ 119 | 120 | * 121 | | ``products()`` [:meth:`API Documentation `] 122 | | Get a list of available currency pairs for trading. 123 | 124 | * 125 | | ``order_book(product_id, level=1)`` [:meth:`API Documentation `] 126 | | Get a list of open orders for a product. 127 | 128 | * 129 | | ``ticker(product_id)`` [:meth:`API Documentation `] 130 | | Get information about the last trade for a product. 131 | 132 | * 133 | | ``trades(product_id, limit=100, before=None, after=None)`` [:meth:`API Documentation `] 134 | | List the latest trades for a product. 135 | 136 | * 137 | | ``historic_rates(product_id, granularity=3600, start=None, stop=None)`` [:meth:`API Documentation `] 138 | | Get historic rates for a product. 139 | 140 | * 141 | | ``get_24hour_stats(product_id)`` [:meth:`API Documentation `] 142 | | Get 24 hr stats for a product. 143 | 144 | Currency 145 | ++++++++ 146 | 147 | * 148 | | ``currencies()`` [:meth:`API Documentation `] 149 | | List known currencies. 150 | 151 | Time 152 | ++++ 153 | 154 | * 155 | | ``server_time`` [:meth:`API Documentation `] 156 | | Get the API server time. 157 | 158 | 159 | Private (Authenticated) Client Methods 160 | -------------------------------------- 161 | 162 | Coinbase labels its REST endpoints for account and order management as "private." Private in this sense means that they require authentication with the API server by signing all requests with a Coinbase API key. To use the corresponding ``copra.rest.Client`` methods you will need your own Coinbase API key. To learn how to create an API key see the Coinbase Pro support article titled `"How do I create an API key for Coinbase Pro?" `_ 163 | 164 | Then you will need to initialize ``copra.rest.Client`` with that API key: 165 | 166 | 167 | .. code:: python 168 | 169 | import asyncio 170 | 171 | from copra.rest import Client 172 | 173 | loop = asyncio.get_event_loop() 174 | 175 | client = Client(loop, auth=True, key=YOUR_KEY, 176 | secret=YOUR_SECRET, passphrase=YOUR_PASSPHRASE) 177 | 178 | .. Note:: Even if you have created an authenticated client, it will only sign the requests to the Coinbase API server that require authentication. The "public" market data methods will still be made unsigned. 179 | 180 | The Coinbase API documentation groups the "private" authenticated methods into these categories: accounts, orders, fills, deposits, withdrawals, stablecoin conversions, payment methods, Coinbase accounts, reports, and user account. 181 | 182 | Again there is a one-to-one mapping from ``copra.rest.Client`` methods and their respective Coinbase API endpoints, but this time there is one exception. Coinbase has a single endpoint, "/orders" for placing orders. This enpoint handles both limit and market orders as well as the stop versions of both. Because of the number of parameters needed to cover all types of orders as well as the complicated interactions between the them, the decision was made to split this enpoint into two methods: :meth:`copra.rest.Client.limit_order` and :meth:`copra.rest.Client.market_order`. 183 | 184 | Accounts 185 | ++++++ 186 | 187 | * 188 | | ``accounts()`` [:meth:`API Documentation `] 189 | | Get a list of your Coinbase Pro trading accounts. 190 | 191 | * 192 | | ``account(account_id)`` [:meth:`API Documentation `] 193 | | Retrieve information for a single account. 194 | 195 | * 196 | | ``account_history(account_id, limit=100, before=None, after=None)`` [:meth:`API Documentation `] 197 | | Retrieve a list account activity. 198 | 199 | * 200 | | ``holds(account_id, limit=100, before=None, after=None)`` [:meth:`API Documentation `] 201 | | Get any existing holds on an account. 202 | 203 | Orders 204 | ++++++ 205 | 206 | * 207 | | ``limit_order(side, product_id, price, size, time_in_force='GTC', cancel_after=None, post_only=False, client_oid=None, stp='dc',stop=None, stop_price=None)`` [:meth:`API Documentation `] 208 | | Place a limit order or a stop entry/loss limit order. 209 | 210 | * 211 | | ``market_order(self, side, product_id, size=None, funds=None, client_oid=None, stp='dc', stop=None, stop_price=None)`` [:meth:`API Documentation `] 212 | | Place a market order or a stop entry/loss market order. 213 | 214 | * 215 | | ``cancel(order_id)`` [:meth:`API Documentation `] 216 | | Cancel a previously placed order. 217 | 218 | * 219 | | ``cancel_all(product_id=None, stop=False)`` [:meth:`API Documentation `] 220 | | Cancel "all" orders. 221 | 222 | * 223 | | ``orders(status=None, product_id=None, limit=100, before=None, after=None)`` [:meth:`API Documentation `] 224 | | Retrieve a list orders 225 | 226 | * 227 | | ``get_order(self, order_id)`` [:meth:`API Documentation `] 228 | | Get a single order by order id. 229 | 230 | Fills 231 | +++++ 232 | 233 | * 234 | | ``fills(order_id='', product_id='', limit=100, before=None, after=None)`` [:meth:`API Documentation `] 235 | | Get a list of recent fills. 236 | 237 | Deposits 238 | ++++++++ 239 | 240 | * 241 | | ``deposit_payment_method(amount, currency, payment_method_id)`` [:meth:`API Documentation `] 242 | | Deposit funds from a payment method on file. 243 | 244 | * 245 | | ``deposit_coinbase(amount, currency, coinbase_account_id)`` [:meth:`API Documentation `] 246 | | Deposit funds from a Coinbase account. 247 | 248 | Withdrawals 249 | +++++++++++ 250 | 251 | * 252 | | ``withdraw_payment_method(self, amount, currency, payment_method_id)`` [:meth:`API Documentation `] 253 | | Withdraw funds to a payment method on file. 254 | 255 | * 256 | | ``withdraw_coinbase(amount, currency, coinbase_account_id)`` [:meth:`API Documentation `] 257 | | Withdraw funds to a Coinbase account. 258 | 259 | * 260 | | ``withdraw_crypto(amount, currency, crypto_address)`` [:meth:`API Documentation `] 261 | | Withdraw funds to a crypto address. 262 | 263 | Stablecoin Conversions 264 | ++++++++++++++++++++++ 265 | 266 | * 267 | | ``stablecoin_conversion(from_currency_id, to_currency_id, amount)`` [:meth:`API Documentation `] 268 | | Convert to and from a stablecoin. 269 | 270 | Payment Methods 271 | +++++++++++++++ 272 | 273 | * 274 | | ``payment_methods()`` [:meth:`API Documentation `] 275 | | Get a list of the payment methods you have on file. 276 | 277 | Fees 278 | +++++++++++++++ 279 | 280 | * 281 | | ``fees()`` [:meth:`API Documentation `] 282 | | Get your current maker & taker fee rates and 30-day trailing volume. 283 | 284 | Coinbase Accounts 285 | +++++++++++++++++ 286 | 287 | * 288 | | ``coinbase_accounts()`` [:meth:`API Documentation `] 289 | | Get a list of your coinbase accounts. 290 | 291 | Reports 292 | +++++++ 293 | 294 | * 295 | | ``create_report(report_type, start_date, end_date, product_id='', account_id='', report_format='pdf', email='')`` [:meth:`API Documentation `] 296 | | Create a report about your account history. 297 | 298 | * 299 | | ``report_status(report_id)`` [:meth:`API Documentation `] 300 | | Get the status of a report. 301 | 302 | User Account 303 | ++++++++++++ 304 | 305 | * 306 | | ``trailing_volume()`` [:meth:`API Documentation `] 307 | | Return your 30-day trailing volume for all products. 308 | -------------------------------------------------------------------------------- /tests/unit/rest/test_client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Unit tests for `copra.rest.Client` class. 4 | """ 5 | 6 | import asyncio 7 | from datetime import datetime, timedelta 8 | import json 9 | import time 10 | import urllib.parse 11 | 12 | import aiohttp 13 | from asynctest import CoroutineMock 14 | from multidict import MultiDict 15 | 16 | from copra.rest import APIRequestError, Client, URL 17 | from copra.rest.client import HEADERS 18 | from tests.unit.rest.util import MockTestCase 19 | 20 | # These are made up 21 | TEST_KEY = 'a035b37f42394a6d343231f7f772b99d' 22 | TEST_SECRET = 'aVGe54dHHYUSudB3sJdcQx4BfQ6K5oVdcYv4eRtDN6fBHEQf5Go6BACew4G0iFjfLKJHmWY5ZEwlqxdslop4CC==' 23 | TEST_PASSPHRASE = 'a2f9ee4dx2b' 24 | 25 | UNAUTH_HEADERS = HEADERS 26 | 27 | AUTH_HEADERS = HEADERS.copy() 28 | 29 | AUTH_HEADERS.update({ 30 | 'Content-Type': 'Application/JSON', 31 | 'CB-ACCESS-TIMESTAMP': '*', 32 | 'CB-ACCESS-SIGN': '*', 33 | 'CB-ACCESS-KEY': TEST_KEY, 34 | 'CB-ACCESS-PASSPHRASE': TEST_PASSPHRASE 35 | }) 36 | 37 | 38 | class TestRest(MockTestCase): 39 | """Tests for copra.rest.Client""" 40 | 41 | def setUp(self): 42 | super().setUp() 43 | self.client = Client(self.loop) 44 | self.auth_client = Client(self.loop, auth=True, key=TEST_KEY, 45 | secret=TEST_SECRET, passphrase=TEST_PASSPHRASE) 46 | 47 | 48 | def tearDown(self): 49 | self.loop.create_task(self.client.close()) 50 | self.loop.create_task(self.auth_client.close()) 51 | 52 | 53 | async def test__init__(self): 54 | # No loop 55 | with self.assertRaises(TypeError): 56 | client = Client() 57 | 58 | self.assertEqual(self.client.loop, self.loop) 59 | self.assertEqual(self.client.url, URL) 60 | self.assertFalse(self.client.auth) 61 | self.assertIsInstance(self.client.session, aiohttp.ClientSession) 62 | self.assertFalse(self.client.session.closed) 63 | 64 | client = Client(self.loop, 'http://www.example.com') 65 | self.assertEqual(client.url,'http://www.example.com') 66 | await client.session.close() 67 | 68 | #auth, no key, secret, or passphrase 69 | with self.assertRaises(ValueError): 70 | client = Client(self.loop, auth=True) 71 | 72 | #auth, key, no secret or passphrase 73 | with self.assertRaises(ValueError): 74 | client = Client(self.loop, auth=True, key='MyKey') 75 | 76 | #auth, key, secret, no passphrase 77 | with self.assertRaises(ValueError): 78 | client = Client(self.loop, auth=True, key='MyKey', secret='MySecret') 79 | 80 | #auth, secret, no key or passphrase 81 | with self.assertRaises(ValueError): 82 | client = Client(self.loop, auth=True, secret='MySecret') 83 | 84 | #auth, secret, passphrase, no key 85 | with self.assertRaises(ValueError): 86 | client = Client(self.loop, auth=True, secret='MySecret', 87 | passphrase='MyPassphrase') 88 | 89 | #auth, passphrase, no key or secret 90 | with self.assertRaises(ValueError): 91 | client = Client(self.loop, auth=True, passphrase='MyPassphrase') 92 | 93 | #auth, key, secret, passphrase 94 | self.assertTrue(self.auth_client.auth) 95 | self.assertEqual(self.auth_client.key, TEST_KEY) 96 | self.assertEqual(self.auth_client.secret, TEST_SECRET) 97 | self.assertEqual(self.auth_client.passphrase, TEST_PASSPHRASE) 98 | 99 | 100 | async def test_close(self): 101 | client = Client(self.loop) 102 | self.assertFalse(client.session.closed) 103 | self.assertFalse(client.closed) 104 | await client.close() 105 | self.assertTrue(client.session.closed) 106 | self.assertTrue(client.closed) 107 | 108 | 109 | async def test_context_manager(self): 110 | async with Client(self.loop) as client: 111 | self.assertFalse(client.closed) 112 | self.assertTrue(client.closed) 113 | 114 | try: 115 | async with Client(self.loop) as client: 116 | raise ValueError() 117 | except ValueError: 118 | pass 119 | self.assertTrue(client.closed) 120 | 121 | 122 | async def test__get_auth_headers(self): 123 | 124 | async with Client(self.loop) as client: 125 | with self.assertRaises(ValueError): 126 | client._get_auth_headers('/mypath') 127 | 128 | path = '/mypath' 129 | timestamp = 1539968909.917318 130 | 131 | # Default GET 132 | headers = self.auth_client._get_auth_headers(path, timestamp=timestamp) 133 | self.assertIsInstance(headers, dict) 134 | self.assertEqual(headers['Content-Type'], 'Application/JSON') 135 | self.assertEqual(headers['CB-ACCESS-SIGN'], 'haapGobLuJMel4ku5s7ptzyNkQdYtLPMXgQJq5f1/cg=') 136 | self.assertEqual(headers['CB-ACCESS-TIMESTAMP'], str(timestamp)) 137 | self.assertEqual(headers['CB-ACCESS-KEY'], TEST_KEY) 138 | self.assertEqual(headers['CB-ACCESS-PASSPHRASE'], TEST_PASSPHRASE) 139 | 140 | # Explicit GET 141 | headers = self.auth_client._get_auth_headers(path, method='GET', timestamp=timestamp) 142 | self.assertIsInstance(headers, dict) 143 | self.assertEqual(headers['Content-Type'], 'Application/JSON') 144 | self.assertEqual(headers['CB-ACCESS-SIGN'], 'haapGobLuJMel4ku5s7ptzyNkQdYtLPMXgQJq5f1/cg=') 145 | self.assertEqual(headers['CB-ACCESS-TIMESTAMP'], str(timestamp)) 146 | self.assertEqual(headers['CB-ACCESS-KEY'], TEST_KEY) 147 | self.assertEqual(headers['CB-ACCESS-PASSPHRASE'], TEST_PASSPHRASE) 148 | 149 | # POST 150 | headers = self.auth_client._get_auth_headers(path, method='POST', timestamp=timestamp) 151 | self.assertIsInstance(headers, dict) 152 | self.assertEqual(headers['Content-Type'], 'Application/JSON') 153 | self.assertEqual(headers['CB-ACCESS-SIGN'], 'Geo8uJefQp5CG42SYsmKW1lvR7t+28ujcgt3yRM1mpA=') 154 | self.assertEqual(headers['CB-ACCESS-TIMESTAMP'], str(timestamp)) 155 | self.assertEqual(headers['CB-ACCESS-KEY'], TEST_KEY) 156 | self.assertEqual(headers['CB-ACCESS-PASSPHRASE'], TEST_PASSPHRASE) 157 | 158 | # DELETE 159 | headers = self.auth_client._get_auth_headers(path, method='DELETE', timestamp=timestamp) 160 | self.assertIsInstance(headers, dict) 161 | self.assertEqual(headers['Content-Type'], 'Application/JSON') 162 | self.assertEqual(headers['CB-ACCESS-SIGN'], 'NRdGfZaOAkFK2ENVDJQ43Rg+fLm+6vg4PML/yzmtuiY=') 163 | self.assertEqual(headers['CB-ACCESS-TIMESTAMP'], str(timestamp)) 164 | self.assertEqual(headers['CB-ACCESS-KEY'], TEST_KEY) 165 | self.assertEqual(headers['CB-ACCESS-PASSPHRASE'], TEST_PASSPHRASE) 166 | 167 | 168 | async def test__handle_error(self): 169 | self.mock_get.return_value.status = 404 170 | self.mock_get.return_value.json.return_value = {'message': 'ERROR MESSAGE'} 171 | 172 | with self.assertRaises(APIRequestError) as cm: 173 | headers, body = await self.client.get('http://www.example.com/fail') 174 | 175 | err = cm.exception 176 | self.assertEqual(err.__str__(), 'ERROR MESSAGE [404]') 177 | self.assertEqual(err.response, self.mock_get.return_value) 178 | 179 | 180 | async def test_delete(self): 181 | path = '/mypath' 182 | query = {'key1': 'item1', 'key2': 'item2'} 183 | 184 | # Unauthorized call by unauthorized client 185 | resp = await self.client.delete(path, query) 186 | self.check_req(self.mock_del, '{}{}'.format(URL, path), query=query, headers=UNAUTH_HEADERS) 187 | 188 | # Unauthorized call by unauthorized client, no query 189 | resp = await self.client.delete(path) 190 | self.check_req(self.mock_del, '{}{}'.format(URL, path), query={}, headers=UNAUTH_HEADERS) 191 | 192 | # Authorized call by unauthorized client 193 | with self.assertRaises(ValueError): 194 | resp = await self.client.delete(path, query, auth=True) 195 | 196 | # Unauthorized call by authorized client 197 | resp = await self.auth_client.delete(path, query) 198 | self.check_req(self.mock_del, '{}{}'.format(URL, path), query=query, headers=UNAUTH_HEADERS) 199 | 200 | # Authorized call by authorized client 201 | resp = await self.auth_client.delete(path, query, auth=True) 202 | self.check_req(self.mock_del, '{}{}'.format(URL, path), query=query, headers=AUTH_HEADERS) 203 | 204 | qs = '?{}'.format(urllib.parse.urlencode(query)) 205 | expected_headers = self.auth_client._get_auth_headers(path + qs, 'DELETE', timestamp=self.mock_del.headers['CB-ACCESS-TIMESTAMP']) 206 | self.assertEqual(self.mock_del.headers['CB-ACCESS-SIGN'], expected_headers['CB-ACCESS-SIGN']) 207 | 208 | 209 | async def test_get(self): 210 | path = '/mypath' 211 | query = {'key1': 'item1', 'key2': 'item2'} 212 | 213 | # Unauthorized call by unauthorized client 214 | resp = await self.client.get(path, query) 215 | self.check_req(self.mock_get, '{}{}'.format(URL, path), query=query, headers=UNAUTH_HEADERS) 216 | 217 | # Unauthorized call by unauthorized client, no query 218 | resp = await self.client.get(path) 219 | self.check_req(self.mock_get, '{}{}'.format(URL, path), query={}, headers=UNAUTH_HEADERS) 220 | 221 | # Authorized call by unauthorized client 222 | with self.assertRaises(ValueError): 223 | resp = await self.client.get(path, query, auth=True) 224 | 225 | # Unauthorized call by authorized client 226 | resp = await self.auth_client.get(path, query) 227 | self.check_req(self.mock_get, '{}{}'.format(URL, path), query=query, headers=UNAUTH_HEADERS) 228 | 229 | # Authorized call by authorized client 230 | resp = await self.auth_client.get(path, query, auth=True) 231 | self.check_req(self.mock_get, '{}{}'.format(URL, path), query=query, headers=AUTH_HEADERS) 232 | 233 | qs = '?{}'.format(urllib.parse.urlencode(query)) 234 | expected_headers = self.auth_client._get_auth_headers(path + qs, 'GET', timestamp=self.mock_get.headers['CB-ACCESS-TIMESTAMP']) 235 | self.assertEqual(self.mock_get.headers['CB-ACCESS-SIGN'], expected_headers['CB-ACCESS-SIGN']) 236 | 237 | 238 | async def test_post(self): 239 | path = '/mypath' 240 | data = {'key1': 'item1', 'key2': 'item2'} 241 | 242 | # Unauthorized call by unauthorized client 243 | resp = await self.client.post(path, data) 244 | self.check_req(self.mock_post, '{}{}'.format(URL, path), data=data, headers=UNAUTH_HEADERS) 245 | 246 | # Unauthorized call by unauthorized client, no data 247 | resp = await self.client.post(path) 248 | self.check_req(self.mock_post, '{}{}'.format(URL, path), data={}, headers=UNAUTH_HEADERS) 249 | 250 | # Authorized call by unauthorized client 251 | with self.assertRaises(ValueError): 252 | resp = await self.client.post(path, data, auth=True) 253 | 254 | # Unauthorized call by authorized client 255 | resp = await self.auth_client.post(path, data) 256 | self.check_req(self.mock_post, '{}{}'.format(URL, path), data=data, headers=UNAUTH_HEADERS) 257 | 258 | # Authorized call by authorized client 259 | resp = await self.auth_client.post(path, data, auth=True) 260 | self.check_req(self.mock_post, '{}{}'.format(URL, path), data=data, headers=AUTH_HEADERS) 261 | 262 | data = json.dumps(data) 263 | expected_headers = self.auth_client._get_auth_headers(path, 'POST', data=data, timestamp=self.mock_post.headers['CB-ACCESS-TIMESTAMP']) 264 | self.assertEqual(self.mock_post.headers['CB-ACCESS-SIGN'], expected_headers['CB-ACCESS-SIGN']) 265 | 266 | 267 | async def test_products(self): 268 | 269 | products = await self.client.products() 270 | self.check_req(self.mock_get, '{}{}'.format(URL, '/products'), headers=UNAUTH_HEADERS) 271 | 272 | 273 | async def test_order_book(self): 274 | 275 | with self.assertRaises(TypeError): 276 | ob = await self.client.order_book() 277 | 278 | with self.assertRaises(ValueError): 279 | ob = await self.client.order_book('BTC-USD', 99) 280 | 281 | # Default level 1 282 | ob = await self.client.order_book('BTC-USD') 283 | self.check_req(self.mock_get, '{}/products/BTC-USD/book'.format(URL), 284 | query={'level': '1'}, headers=UNAUTH_HEADERS) 285 | 286 | # Level 1 287 | ob = await self.client.order_book('BTC-USD', level=1) 288 | self.check_req(self.mock_get, '{}/products/BTC-USD/book'.format(URL), 289 | query={'level': '1'}, headers=UNAUTH_HEADERS) 290 | 291 | # Level 2 292 | ob = await self.client.order_book('BTC-USD', level=2) 293 | self.check_req(self.mock_get, '{}/products/BTC-USD/book'.format(URL), 294 | query={'level': '2'}, headers=UNAUTH_HEADERS) 295 | 296 | # Level 3 297 | ob = await self.client.order_book('BTC-USD', level=3) 298 | self.check_req(self.mock_get, '{}/products/BTC-USD/book'.format(URL), 299 | query={'level': '3'}, headers=UNAUTH_HEADERS) 300 | 301 | 302 | async def test_ticker(self): 303 | 304 | # No product_id 305 | with self.assertRaises(TypeError): 306 | tick = await self.client.ticker() 307 | 308 | tick = await self.client.ticker('BTC-USD') 309 | self.check_req(self.mock_get, '{}/products/BTC-USD/ticker'.format(URL), 310 | headers=UNAUTH_HEADERS) 311 | 312 | 313 | async def test_trades(self): 314 | 315 | # No product_id 316 | with self.assertRaises(TypeError): 317 | trades, before, after = await self.client.trades() 318 | 319 | # before and after 320 | with self.assertRaises(ValueError): 321 | trades, before, after = await self.client.trades('BTC-USD', before=1, after=100) 322 | 323 | trades, before, after = await self.client.trades('BTC-USD') 324 | self.check_req(self.mock_get, '{}/products/BTC-USD/trades'.format(URL), 325 | query={'limit': '100'}, headers=UNAUTH_HEADERS) 326 | 327 | ret_headers = {'cb-before': '51590012', 'cb-after': '51590010'} 328 | body = [{'trade_id': 1}, {'trade_id': 2}] 329 | 330 | self.mock_get.return_value.headers = ret_headers 331 | self.mock_get.return_value.json.return_value = body 332 | 333 | trades, before, after = await self.client.trades('BTC-USD', limit=5) 334 | self.check_req(self.mock_get, '{}/products/BTC-USD/trades'.format(URL), 335 | query={'limit': '5'}, headers=UNAUTH_HEADERS) 336 | self.assertEqual(trades, body) 337 | self.assertEqual(before, ret_headers['cb-before']) 338 | self.assertEqual(after, ret_headers['cb-after']) 339 | 340 | prev_trades, prev_before, prev_after = await self.client.trades('BTC-USD', before=before) 341 | self.check_req(self.mock_get, '{}/products/BTC-USD/trades'.format(URL), 342 | query={'limit': '100', 'before': before}, headers=UNAUTH_HEADERS) 343 | 344 | next_trades, next_before, next_after = await self.client.trades('BTC-USD', after=after) 345 | self.check_req(self.mock_get, '{}/products/BTC-USD/trades'.format(URL), 346 | query={'limit': '100', 'after': after}, headers=UNAUTH_HEADERS) 347 | 348 | 349 | async def test_historic_rates(self): 350 | 351 | # No product_id 352 | with self.assertRaises(TypeError): 353 | rates = await self.client.historic_rates() 354 | 355 | # Invalid granularity 356 | with self.assertRaises(ValueError): 357 | rates = await self.client.historic_rates('BTC-USD', granularity=100) 358 | 359 | end = datetime.utcnow() 360 | start = end - timedelta(days=1) 361 | 362 | # start, no end 363 | with self.assertRaises(ValueError): 364 | rates = await self.client.historic_rates('BTC-USD', start=start) 365 | 366 | #end, no start 367 | with self.assertRaises(ValueError): 368 | rates = await self.client.historic_rates('BTC-USD', end=end) 369 | 370 | # Default granularity 371 | rates = await self.client.historic_rates('BTC-USD') 372 | self.check_req(self.mock_get, '{}/products/BTC-USD/candles'.format(URL), 373 | query={'granularity': '3600'}, headers=UNAUTH_HEADERS) 374 | 375 | # Custom granularity, start, end 376 | rates = await self.client.historic_rates('BTC-USD', 900, 377 | start.isoformat(), 378 | end.isoformat()) 379 | self.check_req(self.mock_get, '{}/products/BTC-USD/candles'.format(URL), 380 | query={'granularity': '900', 'start': start.isoformat(), 381 | 'end': end.isoformat()}, 382 | headers=UNAUTH_HEADERS) 383 | 384 | async def test_get_24hour_stats(self): 385 | 386 | # No product_id 387 | with self.assertRaises(TypeError): 388 | stats = await self.client.get_24hour_stats() 389 | 390 | stats = await self.client.get_24hour_stats('BTC-USD') 391 | self.check_req(self.mock_get, '{}/products/BTC-USD/stats'.format(URL), headers=UNAUTH_HEADERS) 392 | 393 | 394 | async def test_currencies(self): 395 | 396 | currencies = await self.client.currencies() 397 | self.check_req(self.mock_get, '{}/currencies'.format(URL), headers=UNAUTH_HEADERS) 398 | 399 | 400 | async def test_server_time(self): 401 | 402 | time = await self.client.server_time() 403 | self.check_req(self.mock_get, '{}/time'.format(URL), headers=UNAUTH_HEADERS) 404 | 405 | 406 | async def test_accounts(self): 407 | 408 | # Unauthorized client 409 | with self.assertRaises(ValueError): 410 | accounts = await self.client.accounts() 411 | 412 | accounts = await self.auth_client.accounts() 413 | self.check_req(self.mock_get, '{}/accounts'.format(URL), headers=AUTH_HEADERS) 414 | 415 | 416 | async def test_account(self): 417 | 418 | # No account_id 419 | with self.assertRaises(TypeError): 420 | trades, before, after = await self.auth_client.account() 421 | 422 | # Unauthorized client 423 | with self.assertRaises(ValueError): 424 | acount = await self.client.account(42) 425 | 426 | account = await self.auth_client.account(42) 427 | self.check_req(self.mock_get, '{}/accounts/42'.format(URL), headers=AUTH_HEADERS) 428 | 429 | 430 | async def test_account_history(self): 431 | 432 | # No account_id 433 | with self.assertRaises(TypeError): 434 | trades, before, after = await self.auth_client.account_history() 435 | 436 | # Unauthorized client 437 | with self.assertRaises(ValueError): 438 | trades, before, after = await self.client.account_history(42) 439 | 440 | # before and after both set 441 | with self.assertRaises(ValueError): 442 | trades, before, after = await self.auth_client.account_history(42, 443 | before='earlier', after='later') 444 | 445 | ret_headers = {'cb-before': '1071064024', 'cb-after': '1008063508'} 446 | body = [{'id': '1'}, {'id': '2'}] 447 | 448 | self.mock_get.return_value.headers = ret_headers 449 | self.mock_get.return_value.json.return_value = body 450 | 451 | trades, before, after = await self.auth_client.account_history(42, limit=5) 452 | self.assertEqual(before, '1071064024') 453 | self.assertEqual(after, '1008063508') 454 | self.assertEqual(trades, body) 455 | self.check_req(self.mock_get, '{}/accounts/42/ledger'.format(URL), 456 | query={'limit': '5'}, headers=AUTH_HEADERS) 457 | 458 | 459 | trades, before, after = await self.auth_client.account_history(42, before=before) 460 | self.check_req(self.mock_get, '{}/accounts/42/ledger'.format(URL), 461 | query={'limit': '100', 'before': before}, headers=AUTH_HEADERS) 462 | 463 | trades, before, after = await self.auth_client.account_history(42, after=after) 464 | self.check_req(self.mock_get, '{}/accounts/42/ledger'.format(URL), 465 | query={'limit': '100', 'after': after}, headers=AUTH_HEADERS) 466 | 467 | 468 | async def test_holds(self): 469 | 470 | # No account_id 471 | with self.assertRaises(TypeError): 472 | holds, before, after = await self.auth_client.holds() 473 | 474 | # Unauthorized client 475 | with self.assertRaises(ValueError): 476 | holds, before, after = await self.client.holds(42) 477 | 478 | # before and after both set 479 | with self.assertRaises(ValueError): 480 | holds, before, after = await self.auth_client.holds(42, 481 | before='earlier', after='later') 482 | 483 | ret_headers = {'cb-before': '1071064024', 'cb-after': '1008063508'} 484 | body = [{'id': '1'}, {'id': '2'}] 485 | 486 | self.mock_get.return_value.headers = ret_headers 487 | self.mock_get.return_value.json.return_value = body 488 | 489 | holds, before, after = await self.auth_client.holds(42, limit=5) 490 | self.assertEqual(before, '1071064024') 491 | self.assertEqual(after, '1008063508') 492 | self.assertEqual(holds, body) 493 | self.check_req(self.mock_get, '{}/accounts/42/holds'.format(URL), 494 | query={'limit': '5'}, headers=AUTH_HEADERS) 495 | 496 | holds, before, after = await self.auth_client.holds(42, before=before) 497 | self.check_req(self.mock_get, '{}/accounts/42/holds'.format(URL), 498 | query={'limit': '100', 'before': before}, headers=AUTH_HEADERS) 499 | 500 | holds, before, after = await self.auth_client.holds(42, after=after) 501 | self.check_req(self.mock_get, '{}/accounts/42/holds'.format(URL), 502 | query={'limit': '100', 'after': after}, headers=AUTH_HEADERS) 503 | 504 | 505 | async def test_limit_order(self): 506 | 507 | # Unauthorized client 508 | with self.assertRaises(ValueError): 509 | resp = await self.client.limit_order('buy', 'BTC-USD', 1, 100) 510 | 511 | # Invalid side 512 | with self.assertRaises(ValueError): 513 | resp = await self.auth_client.limit_order('right', 'BTC-USD', 1, 100) 514 | 515 | # Invalid time_in_force 516 | with self.assertRaises(ValueError): 517 | resp = await self.auth_client.limit_order('buy', 'BTC-USD', 100, 5, 518 | time_in_force='OPP') 519 | 520 | # GTT time_in_force, no cancel_after 521 | with self.assertRaises(ValueError): 522 | resp = await self.auth_client.limit_order('buy', 'BTC_USD', 2, 60, 523 | time_in_force='GTT') 524 | 525 | # Invalid cancel_after 526 | with self.assertRaises(ValueError): 527 | resp = await self.auth_client.limit_order('buy', 'BTC-USD', 3, 47, 528 | time_in_force='GTT', 529 | cancel_after='lifetime') 530 | 531 | # cancel_after wrong time_in_force 532 | with self.assertRaises(ValueError): 533 | resp = await self.auth_client.limit_order('buy', 'BTC-USD', 4, 33, 534 | time_in_force='FOK', 535 | cancel_after='hour') 536 | 537 | # IOC time_in_force, post_only True 538 | with self.assertRaises(ValueError): 539 | resp = await self.auth_client.limit_order('buy', 'BTC-USD', 5, 82, 540 | time_in_force='IOC', 541 | post_only=True) 542 | 543 | # FOK time_in_force, post_only True 544 | with self.assertRaises(ValueError): 545 | resp = await self.auth_client.limit_order('buy', 'BTC-USD', 6, 12, 546 | time_in_force='FOK', 547 | post_only=True) 548 | 549 | # Invalid stp 550 | with self.assertRaises(ValueError): 551 | resp = await self.auth_client.limit_order('buy', 'BTC-USD', 7, 14, 552 | stp='Core') 553 | 554 | # Default order_type, time_in_force 555 | resp = await self.auth_client.limit_order('buy', 'BTC-USD', 1.1, 3.14) 556 | 557 | self.check_req(self.mock_post, '{}/orders'.format(URL), 558 | data={'side': 'buy', 'product_id': 'BTC-USD', 559 | 'type': 'limit', 'price': 1.1, 560 | 'size': 3.14, 'time_in_force': 'GTC', 561 | 'post_only': False, 'stp': 'dc'}, 562 | headers=AUTH_HEADERS) 563 | 564 | # GTT order with cancel_after 565 | resp = await self.auth_client.limit_order('buy', 'BTC-USD', 1.7, 29, 566 | time_in_force='GTT', cancel_after='hour') 567 | 568 | self.check_req(self.mock_post, '{}/orders'.format(URL), 569 | data={'side': 'buy', 'product_id': 'BTC-USD', 570 | 'type': 'limit', 'price': 1.7, 'size': 29, 571 | 'time_in_force': 'GTT', 'cancel_after': 'hour', 572 | 'post_only': False, 'stp': 'dc'}, 573 | headers=AUTH_HEADERS) 574 | 575 | # client_oid, stp 576 | resp = await self.auth_client.limit_order('buy', 'LTC-USD', 0.8, 11, 577 | client_oid='back', stp='cb') 578 | 579 | self.check_req(self.mock_post, '{}/orders'.format(URL), 580 | data={'side': 'buy', 'product_id': 'LTC-USD', 581 | 'type': 'limit', 'price': 0.8, 'size': 11, 582 | 'time_in_force': 'GTC', 'client_oid': 'back', 583 | 'post_only': False, 'stp': 'cb'}, 584 | headers=AUTH_HEADERS) 585 | 586 | 587 | async def test_limit_order_stop(self): 588 | 589 | # Invalid stop 590 | with self.assertRaises(ValueError): 591 | resp = await self.auth_client.limit_order('buy', 'BTC-USD', 1, .001, 592 | stop="Hammer Time", stop_price=10) 593 | # stop w/ no stop_price 594 | with self.assertRaises(ValueError): 595 | resp = await self.auth_client.limit_order('buy', 'BTC-USD', 1, .001, 596 | stop="loss") 597 | 598 | # stop_price w/ no stop 599 | with self.assertRaises(ValueError): 600 | resp = await self.auth_client.limit_order('buy', 'BTC-USD', 2, .002, 601 | stop_price=10) 602 | 603 | # stop w/ post_only True 604 | with self.assertRaises(ValueError): 605 | resp = await self.auth_client.limit_order('buy', 'LTC-USD', 3, .003, 606 | stop='entry', stop_price=10000, post_only=True) 607 | 608 | # stop loss 609 | resp = await self.auth_client.limit_order('sell', 'BTC-USD', 3.5, .004, 610 | stop='loss', stop_price=4) 611 | 612 | self.check_req(self.mock_post, '{}/orders'.format(URL), 613 | data={'side': 'sell', 'product_id': 'BTC-USD', 614 | 'type': 'limit', 'price': 3.5, 615 | 'size': .004, 'time_in_force': 'GTC', 616 | 'post_only': False, 'stp': 'dc', 'stop': 'loss', 617 | 'stop_price': 4}, 618 | headers=AUTH_HEADERS) 619 | 620 | # stop entry 621 | resp = await self.auth_client.limit_order('buy', 'BTC-USD', 10000, .005, 622 | stop='entry', stop_price=11000) 623 | 624 | self.check_req(self.mock_post, '{}/orders'.format(URL), 625 | data={'side': 'buy', 'product_id': 'BTC-USD', 626 | 'type': 'limit', 'price': 10000, 627 | 'size': .005, 'time_in_force': 'GTC', 628 | 'post_only': False, 'stp': 'dc', 'stop': 'entry', 629 | 'stop_price': 11000}, 630 | headers=AUTH_HEADERS) 631 | 632 | 633 | async def test_market_order(self): 634 | 635 | # Unauthorized client 636 | with self.assertRaises(ValueError): 637 | resp = await self.client.market_order('buy', 'BTC-USD', .001) 638 | 639 | # Invalid side 640 | with self.assertRaises(ValueError): 641 | resp = await self.auth_client.market_order('dark', 'BTC-USD', .001) 642 | 643 | # No funds or size 644 | with self.assertRaises(ValueError): 645 | resp = await self.auth_client.market_order('buy', 'BTC-USD') 646 | 647 | # funds and size 648 | with self.assertRaises(ValueError): 649 | resp = await self.auth_client.market_order('buy', 'BTC-USD', 650 | size=.001, funds=10000) 651 | 652 | # Invalid stp 653 | with self.assertRaises(ValueError): 654 | resp = await self.auth_client.market_order('buy', 'BTC-USD', 655 | size=.001, stp='plush') 656 | 657 | # Size, no client_oid, default stp 658 | resp = await self.auth_client.market_order('buy', 'BTC-USD', size=5) 659 | self.check_req(self.mock_post, '{}/orders'.format(URL), 660 | data={'side': 'buy', 'product_id': 'BTC-USD', 661 | 'type': 'market', 'size': 5, 'stp': 'dc'}, 662 | headers=AUTH_HEADERS) 663 | 664 | # Size, client_oid, stp 665 | resp = await self.auth_client.market_order('buy', 'BTC-USD', size=3, 666 | client_oid='Order 66', stp='co') 667 | self.check_req(self.mock_post, '{}/orders'.format(URL), 668 | data={'side': 'buy', 'product_id': 'BTC-USD', 669 | 'type': 'market', 'size': 3, 'client_oid': 'Order 66', 670 | 'stp': 'co'}, 671 | headers=AUTH_HEADERS) 672 | 673 | # Funds, no client_oid, default stp 674 | resp = await self.auth_client.market_order('buy', 'BTC-USD', funds=500) 675 | self.check_req(self.mock_post, '{}/orders'.format(URL), 676 | data={'side': 'buy', 'product_id': 'BTC-USD', 677 | 'type': 'market', 'funds': 500, 'stp': 'dc'}, 678 | headers=AUTH_HEADERS) 679 | 680 | # Funds, client_oid, stp 681 | resp = await self.auth_client.market_order('buy', 'BTC-USD', funds=300, 682 | client_oid='of the Jedi', stp='cb') 683 | self.check_req(self.mock_post, '{}/orders'.format(URL), 684 | data={'side': 'buy', 'product_id': 'BTC-USD', 685 | 'type': 'market', 'funds': 300, 686 | 'client_oid': 'of the Jedi', 'stp': 'cb'}, 687 | headers=AUTH_HEADERS) 688 | 689 | 690 | async def test_market_order_stop(self): 691 | 692 | # Invalid stop 693 | with self.assertRaises(ValueError): 694 | resp = await self.auth_client.market_order('buy', 'BTC-USD', .001, 695 | stop="Hammer Time", stop_price=10) 696 | 697 | # stop w/ no stop_price 698 | with self.assertRaises(ValueError): 699 | resp = await self.auth_client.market_order('buy', 'BTC-USD', .001, 700 | stop="loss") 701 | 702 | # stop_price w/ no stop 703 | with self.assertRaises(ValueError): 704 | resp = await self.auth_client.market_order('buy', 'BTC-USD', .001, 705 | stop_price=10) 706 | 707 | # stop loss 708 | resp = await self.auth_client.market_order('sell', 'BTC-USD', size=.002, 709 | stop='loss', stop_price=2.2) 710 | self.check_req(self.mock_post, '{}/orders'.format(URL), 711 | data={'side': 'sell', 'product_id': 'BTC-USD', 712 | 'type': 'market', 'size': .002, 'stp': 'dc', 713 | 'stop': 'loss', 'stop_price': 2.2}, 714 | headers=AUTH_HEADERS) 715 | 716 | # stop entry 717 | resp = await self.auth_client.market_order('buy', 'BTC-USD', size=.003, 718 | stop='entry', stop_price=9000) 719 | self.check_req(self.mock_post, '{}/orders'.format(URL), 720 | data={'side': 'buy', 'product_id': 'BTC-USD', 721 | 'type': 'market', 'size': .003, 'stp': 'dc', 722 | 'stop': 'entry', 'stop_price': 9000}, 723 | headers=AUTH_HEADERS) 724 | 725 | 726 | async def test_cancel(self): 727 | 728 | with self.assertRaises(TypeError): 729 | resp = await self.auth_client.cancel() 730 | 731 | # Unauthorized client 732 | with self.assertRaises(ValueError): 733 | resp = await self.client.cancel(42) 734 | 735 | resp = await self.auth_client.cancel(42) 736 | self.check_req(self.mock_del, '{}/orders/42'.format(URL), headers=AUTH_HEADERS) 737 | 738 | 739 | async def test_cancel_all(self): 740 | 741 | # Unauthorized client 742 | with self.assertRaises(ValueError): 743 | resp = await self.client.cancel_all() 744 | 745 | # No product_idx 746 | resp = await self.auth_client.cancel_all() 747 | self.check_req(self.mock_del, '{}/orders'.format(URL), headers=AUTH_HEADERS) 748 | 749 | # product_id 750 | resp = await self.auth_client.cancel_all('BTC-USD') 751 | self.check_req(self.mock_del, '{}/orders'.format(URL), 752 | query={'product_id': 'BTC-USD'}, headers=AUTH_HEADERS) 753 | 754 | 755 | async def test_orders(self): 756 | 757 | # Unauthorizerd client 758 | with self.assertRaises(ValueError): 759 | orders, before, after = await self.client.orders() 760 | 761 | # before and after both set 762 | with self.assertRaises(ValueError): 763 | orders, before, after = await self.auth_client.orders( 764 | before='nighttime', after='morning') 765 | 766 | # Invalid status string 767 | with self.assertRaises(ValueError): 768 | orders, before, after = await self.auth_client.orders('fresh') 769 | 770 | # Invalid status string in list 771 | with self.assertRaises(ValueError): 772 | orders, before, after = await self.auth_client.orders(['open', 'stale', 'pending']) 773 | 774 | # Default status, default product_id, default limit 775 | orders, before, after = await self.auth_client.orders() 776 | self.check_req(self.mock_get, '{}/orders'.format(URL), 777 | query={'limit': '100'}, headers=AUTH_HEADERS) 778 | 779 | # String status, default product_id, default limit 780 | orders, before, after = await self.auth_client.orders('open') 781 | self.check_req(self.mock_get, '{}/orders'.format(URL), 782 | query={'limit': '100', 'status': 'open'}, headers=AUTH_HEADERS) 783 | 784 | # List status, default product_id, default limit 785 | orders, before, after = await self.auth_client.orders(['pending', 'open']) 786 | self.check_req(self.mock_get, '{}/orders'.format(URL), 787 | query=MultiDict([('limit', '100'), ('status', 'pending'), ('status', 'open')]), 788 | headers=AUTH_HEADERS) 789 | 790 | # product_id, default status, default limit 791 | orders, before, after = await self.auth_client.orders(product_id='BTC-USD') 792 | self.check_req(self.mock_get, '{}/orders'.format(URL), 793 | query={'product_id': 'BTC-USD', 'limit': '100'}, 794 | headers=AUTH_HEADERS) 795 | 796 | # product_id, string status, default limit 797 | orders, before, after = await self.auth_client.orders('open', 'BTC-USD') 798 | self.check_req(self.mock_get, '{}/orders'.format(URL), 799 | query={'status': 'open', 'product_id': 'BTC-USD', 'limit': '100'}, 800 | headers=AUTH_HEADERS) 801 | 802 | # product_id, list status, default limit 803 | orders, before, after = await self.auth_client.orders(['pending', 'active'], 'BTC-USD') 804 | self.check_req(self.mock_get, '{}/orders'.format(URL), 805 | query=MultiDict([('status', 'pending'), ('status', 'active'), ('product_id','BTC-USD'), ('limit', '100')]), 806 | headers=AUTH_HEADERS) 807 | 808 | ret_headers = {'cb-before': '1071064024', 'cb-after': '1008063508'} 809 | 810 | body = [{'order_id': '1'}, {'order_id': '2'}] 811 | 812 | self.mock_get.return_value.headers = ret_headers 813 | self.mock_get.return_value.json.return_value = body 814 | 815 | # product_id, list_status, custom limit 816 | orders, before, after = await self.auth_client.orders('open', 'BTC-USD', limit=5) 817 | self.assertEqual(before, '1071064024') 818 | self.assertEqual(after, '1008063508') 819 | self.assertEqual(orders, body) 820 | self.check_req(self.mock_get, '{}/orders'.format(URL), 821 | query={'status': 'open', 'product_id': 'BTC-USD', 'limit': '5'}, 822 | headers=AUTH_HEADERS) 823 | 824 | orders, before, after = await self.auth_client.orders('open', 'BTC-USD', before=before) 825 | self.check_req(self.mock_get, '{}/orders'.format(URL), 826 | query={'status': 'open', 'product_id': 'BTC-USD', 'limit': '100', 'before': before}, 827 | headers=AUTH_HEADERS) 828 | 829 | orders, before, after = await self.auth_client.orders('open', 'BTC-USD', after=after) 830 | self.check_req(self.mock_get, '{}/orders'.format(URL), 831 | query={'status': 'open', 'product_id': 'BTC-USD', 'limit': '100', 'after': after}, 832 | headers=AUTH_HEADERS) 833 | 834 | 835 | async def test_get_order(self): 836 | 837 | # Unauthorizerd client 838 | with self.assertRaises(ValueError): 839 | order = await self.client.get_order(42) 840 | 841 | # No order_id 842 | with self.assertRaises(TypeError): 843 | order = await self.auth_client.get_order() 844 | 845 | # order_id 846 | order = await self.auth_client.get_order(42) 847 | self.check_req(self.mock_get, '{}/orders/42'.format(URL), headers=AUTH_HEADERS) 848 | 849 | 850 | async def test_fills(self): 851 | 852 | # Unauthorized client 853 | with self.assertRaises(ValueError): 854 | fills, before, after = await self.client.fills() 855 | 856 | # before and after both set 857 | with self.assertRaises(ValueError): 858 | fills, before, after = await self.auth_client.fills('33', 859 | before='BC', after='AD') 860 | 861 | # order_id and product_id not defined 862 | with self.assertRaises(ValueError): 863 | fills, before, after = await self.auth_client.fills() 864 | 865 | # order_id and product_id both defined 866 | with self.assertRaises(ValueError): 867 | fills, before, after = await self.auth_client.fills('42', 'BTC-USD') 868 | 869 | # order_id 870 | fills, before, after = await self.auth_client.fills('42') 871 | self.check_req(self.mock_get, '{}/fills'.format(URL), 872 | query={'order_id': '42', 'limit': '100'}, headers=AUTH_HEADERS) 873 | 874 | ret_headers = {'cb-before': '1071064024', 'cb-after': '1008063508'} 875 | 876 | body = [{'trade_id': '1'}, {'trade_id': '2'}] 877 | 878 | self.mock_get.return_value.headers = ret_headers 879 | self.mock_get.return_value.json.return_value = body 880 | 881 | # product_id 882 | fills, before, after = await self.auth_client.fills(product_id='BTC-USD') 883 | self.check_req(self.mock_get, '{}/fills'.format(URL), 884 | query={'product_id': 'BTC-USD', 'limit': '100'}, headers=AUTH_HEADERS) 885 | 886 | # limit, before cursor 887 | fills, before, after = await self.auth_client.fills('42', limit=5, before=before) 888 | self.check_req(self.mock_get, '{}/fills'.format(URL), 889 | query={'order_id': '42', 'limit': '5', 'before': before}, 890 | headers=AUTH_HEADERS) 891 | 892 | # after cursor 893 | fills, before, after = await self.auth_client.fills('42', after=after) 894 | self.check_req(self.mock_get, '{}/fills'.format(URL), 895 | query={'order_id': '42', 'limit': '100', 'after': after}, 896 | headers=AUTH_HEADERS) 897 | 898 | 899 | async def test_payment_methods(self): 900 | 901 | # Unauthorized client 902 | with self.assertRaises(ValueError): 903 | methods = await self.client.payment_methods() 904 | 905 | methods = await self.auth_client.payment_methods() 906 | self.check_req(self.mock_get, '{}/payment-methods'.format(URL), headers=AUTH_HEADERS) 907 | 908 | 909 | async def test_coinbase_accounts(self): 910 | 911 | # Unauthorized client 912 | with self.assertRaises(ValueError): 913 | accounts = await self.client.coinbase_accounts() 914 | 915 | accounts = await self.auth_client.coinbase_accounts() 916 | self.check_req(self.mock_get, '{}/coinbase-accounts'.format(URL), headers=AUTH_HEADERS) 917 | 918 | 919 | async def test_deposit_payment_method(self): 920 | 921 | # Unauthorized client 922 | with self.assertRaises(ValueError): 923 | resp = await self.client.deposit_payment_method(3.14, 'EUR', '10') 924 | 925 | resp =await self.auth_client.deposit_payment_method(1000, 'USD', '42') 926 | self.check_req(self.mock_post, '{}/deposits/payment-method'.format(URL), 927 | data={'amount': 1000, 'currency': 'USD', 928 | 'payment_method_id': '42'}, 929 | headers=AUTH_HEADERS) 930 | 931 | 932 | async def test_deposit_coinbase(self): 933 | 934 | # Unauthorized client 935 | with self.assertRaises(ValueError): 936 | resp = await self.client.deposit_coinbase(1000, 'BTC', '7') 937 | 938 | resp =await self.auth_client.deposit_coinbase(95, 'LTC', 'A1') 939 | self.check_req(self.mock_post, '{}/deposits/coinbase-account'.format(URL), 940 | data={'amount': 95, 'currency': 'LTC', 941 | 'coinbase_account_id': 'A1'}, 942 | headers=AUTH_HEADERS) 943 | 944 | 945 | async def test_withdraw_payment_method(self): 946 | 947 | # Unauthorized client 948 | with self.assertRaises(ValueError): 949 | resp = await self.client.withdraw_payment_method(104.1, 'USD', 'WNNK') 950 | 951 | resp = await self.auth_client.withdraw_payment_method(93.5, 'USD', 'WTPA') 952 | self.check_req(self.mock_post, '{}/withdrawals/payment-method'.format(URL), 953 | data={'amount': 93.5, 'currency': 'USD', 954 | 'payment_method_id': 'WTPA'}, 955 | headers=AUTH_HEADERS) 956 | 957 | 958 | async def test_withdrawl_coinbase(self): 959 | 960 | # Unauthorized client 961 | with self.assertRaises(ValueError): 962 | resp = await self.client.withdraw_coinbase(1000, 'BTC', '7') 963 | 964 | resp =await self.auth_client.withdraw_coinbase(95, 'LTC', 'A1') 965 | self.check_req(self.mock_post, '{}/withdrawals/coinbase-account'.format(URL), 966 | data={'amount': 95, 'currency': 'LTC', 967 | 'coinbase_account_id': 'A1'}, 968 | headers=AUTH_HEADERS) 969 | 970 | 971 | async def test_withdrawl_crypto(self): 972 | 973 | # Unauthorized client 974 | with self.assertRaises(ValueError): 975 | resp = await self.client.withdraw_crypto(83, 'YES', '90125') 976 | 977 | resp =await self.auth_client.withdraw_crypto(88, 'VH', 'OU812') 978 | self.check_req(self.mock_post, '{}/withdrawals/crypto'.format(URL), 979 | data={'amount': 88, 'currency': 'VH', 980 | 'crypto_address': 'OU812'}, 981 | headers=AUTH_HEADERS) 982 | 983 | 984 | async def test_stablecoin_conversion(self): 985 | 986 | # Unauthorized client 987 | with self.assertRaises(ValueError): 988 | resp = await self.client.stablecoin_conversion('frown', 'smile', 100.1) 989 | 990 | resp =await self.auth_client.stablecoin_conversion('USD', 'USDC', 19.72) 991 | self.check_req(self.mock_post, '{}/conversions'.format(URL), 992 | data={'from': 'USD', 'to': 'USDC', 'amount': 19.72}, 993 | headers=AUTH_HEADERS) 994 | 995 | 996 | async def test_fees(self): 997 | 998 | # Unauthorized client 999 | with self.assertRaises(ValueError): 1000 | fees = await self.client.fees() 1001 | 1002 | fees = await self.auth_client.fees() 1003 | self.check_req(self.mock_get, '{}/fees'.format(URL), headers=AUTH_HEADERS) 1004 | 1005 | 1006 | async def test_create_report(self): 1007 | 1008 | end = datetime.utcnow() 1009 | start = end - timedelta(days=1) 1010 | end = end.isoformat() 1011 | start = start.isoformat() 1012 | 1013 | # Unauthorized client 1014 | with self.assertRaises(ValueError): 1015 | resp = await self.client.create_report('account', start, end) 1016 | 1017 | # Invalid report type 1018 | with self.assertRaises(ValueError): 1019 | resp = await self.auth_client.create_report('TPS', start, end) 1020 | 1021 | # report_type fills, no product_id 1022 | with self.assertRaises(ValueError): 1023 | resp = await self.auth_client.create_report('fills', start, end) 1024 | 1025 | # report_type accounts, no account_id 1026 | with self.assertRaises(ValueError): 1027 | resp = await self.auth_client.create_report('account', start, end) 1028 | 1029 | # invalid format 1030 | with self.assertRaises(ValueError): 1031 | resp = await self.auth_client.create_report('fills', start, end, 1032 | 'BTC-USD', report_format='mp3') 1033 | 1034 | # report type fills, default format 1035 | resp = await self.auth_client.create_report('fills', start, end, 'BTC_USD') 1036 | self.check_req(self.mock_post, '{}/reports'.format(URL), 1037 | data={'type': 'fills', 'product_id': 'BTC_USD', 1038 | 'start_date': start, 'end_date': end, 1039 | 'format': 'pdf'}, 1040 | headers=AUTH_HEADERS) 1041 | 1042 | # report type account, non-default format, email 1043 | resp = await self.auth_client.create_report('account', start, end, 1044 | account_id='R2D2', report_format='csv', 1045 | email='me@example.com') 1046 | self.check_req(self.mock_post, '{}/reports'.format(URL), 1047 | data={'type': 'account', 'account_id': 'R2D2', 1048 | 'start_date': start, 'end_date': end, 1049 | 'format': 'csv', 'email': 'me@example.com'}, 1050 | headers=AUTH_HEADERS) 1051 | 1052 | 1053 | async def test_report_status(self): 1054 | 1055 | # Unathorized client 1056 | with self.assertRaises(ValueError): 1057 | resp = await self.client.report_status('mnopuppies') 1058 | 1059 | # No report_id 1060 | with self.assertRaises(TypeError): 1061 | resp = await self.auth_client.report_status() 1062 | 1063 | resp = await self.auth_client.report_status('icmpn') 1064 | self.check_req(self.mock_get, '{}/reports/icmpn'.format(URL), 1065 | headers=AUTH_HEADERS) 1066 | 1067 | 1068 | async def test_trailing_volume(self): 1069 | 1070 | # Unauthorized client 1071 | with self.assertRaises(ValueError): 1072 | resp = await self.client.trailing_volume() 1073 | 1074 | resp = await self.auth_client.trailing_volume() 1075 | self.check_req(self.mock_get, 1076 | '{}/users/self/trailing-volume'.format(URL), 1077 | headers=AUTH_HEADERS) 1078 | --------------------------------------------------------------------------------