From d5ac41bbed65dbc7ba051939a8e1549add1e7a51 Mon Sep 17 00:00:00 2001 From: David Mkrtchyan Date: Tue, 13 Feb 2024 11:40:43 -0800 Subject: [PATCH] Release v1.1.3 --- .gitignore | 68 + CHANGELOG.md | 49 + LICENSE.md | 201 +++ Makefile | 9 + README.md | 273 ++++ coinbase/__init__.py | 0 coinbase/__version__.py | 1 + coinbase/api_base.py | 59 + coinbase/constants.py | 32 + coinbase/jwt_generator.py | 103 ++ coinbase/rest/__init__.py | 72 + coinbase/rest/accounts.py | 50 + coinbase/rest/common.py | 24 + coinbase/rest/convert.py | 110 ++ coinbase/rest/fees.py | 35 + coinbase/rest/futures.py | 143 ++ coinbase/rest/market_data.py | 66 + coinbase/rest/orders.py | 1842 ++++++++++++++++++++++++ coinbase/rest/perpetuals.py | 107 ++ coinbase/rest/portfolios.py | 169 +++ coinbase/rest/products.py | 123 ++ coinbase/rest/rest_base.py | 239 +++ coinbase/websocket/__init__.py | 38 + coinbase/websocket/channels.py | 620 ++++++++ coinbase/websocket/websocket_base.py | 586 ++++++++ docs/Makefile | 20 + docs/coinbase.rest.rst | 99 ++ docs/coinbase.websocket.rst | 55 + docs/conf.py | 30 + docs/index.rst | 39 + docs/jwt_generator.rst | 7 + docs/make.bat | 33 + docs_requirements.txt | 2 + lint_requirements.txt | 2 + pinned_requirements.txt | 5 + requirements.txt | 5 + setup.py | 49 + test_requirements.txt | 2 + tests/__init__.py | 0 tests/constants.py | 2 + tests/rest/__init__.py | 0 tests/rest/test_accounts.py | 45 + tests/rest/test_common.py | 27 + tests/rest/test_convert.py | 90 ++ tests/rest/test_fees.py | 32 + tests/rest/test_futures.py | 119 ++ tests/rest/test_market_data.py | 54 + tests/rest/test_orders.py | 1404 ++++++++++++++++++ tests/rest/test_perpetuals.py | 98 ++ tests/rest/test_portfolios.py | 130 ++ tests/rest/test_products.py | 83 ++ tests/rest/test_rest_base.py | 172 +++ tests/test_api_base.py | 57 + tests/test_api_key.json | 13 + tests/test_jwt_generator.py | 48 + tests/websocket/__init__.py | 0 tests/websocket/mock_ws_server.py | 70 + tests/websocket/test_channels.py | 204 +++ tests/websocket/test_websocket_base.py | 380 +++++ 59 files changed, 8395 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 coinbase/__init__.py create mode 100644 coinbase/__version__.py create mode 100644 coinbase/api_base.py create mode 100644 coinbase/constants.py create mode 100644 coinbase/jwt_generator.py create mode 100755 coinbase/rest/__init__.py create mode 100644 coinbase/rest/accounts.py create mode 100644 coinbase/rest/common.py create mode 100644 coinbase/rest/convert.py create mode 100644 coinbase/rest/fees.py create mode 100644 coinbase/rest/futures.py create mode 100644 coinbase/rest/market_data.py create mode 100644 coinbase/rest/orders.py create mode 100644 coinbase/rest/perpetuals.py create mode 100644 coinbase/rest/portfolios.py create mode 100644 coinbase/rest/products.py create mode 100644 coinbase/rest/rest_base.py create mode 100644 coinbase/websocket/__init__.py create mode 100644 coinbase/websocket/channels.py create mode 100644 coinbase/websocket/websocket_base.py create mode 100644 docs/Makefile create mode 100644 docs/coinbase.rest.rst create mode 100644 docs/coinbase.websocket.rst create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/jwt_generator.rst create mode 100644 docs/make.bat create mode 100644 docs_requirements.txt create mode 100644 lint_requirements.txt create mode 100644 pinned_requirements.txt create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 test_requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/constants.py create mode 100644 tests/rest/__init__.py create mode 100644 tests/rest/test_accounts.py create mode 100644 tests/rest/test_common.py create mode 100644 tests/rest/test_convert.py create mode 100644 tests/rest/test_fees.py create mode 100644 tests/rest/test_futures.py create mode 100644 tests/rest/test_market_data.py create mode 100644 tests/rest/test_orders.py create mode 100644 tests/rest/test_perpetuals.py create mode 100644 tests/rest/test_portfolios.py create mode 100644 tests/rest/test_products.py create mode 100644 tests/rest/test_rest_base.py create mode 100644 tests/test_api_base.py create mode 100644 tests/test_api_key.json create mode 100644 tests/test_jwt_generator.py create mode 100644 tests/websocket/__init__.py create mode 100644 tests/websocket/mock_ws_server.py create mode 100644 tests/websocket/test_channels.py create mode 100644 tests/websocket/test_websocket_base.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a477af --- /dev/null +++ b/.gitignore @@ -0,0 +1,68 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +coinbase/__pycache__ +coinbase/rest/__pycache__ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg +*.DS_Store + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ +.python-version +test.py \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c3411c4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,49 @@ +# Changelog + +## [1.1.3] - 2024-FEB-13 + +### Added +- Full MyPy annotations with return types for function definitions + +## [1.1.2] - 2024-FEB-9 + +### Added +- Detailed documentation for all exposed functions of the SDK +- Support for PreviewOrder endpoint + +## [1.1.1] - 2024-FEB-1 + +### Added +- Support for Perpetuals API endpoints + +## [1.1.0] - 2024-JAN-31 + +### Added +- Initial release of WebSocket API client +- Verbose logging option for RESTClient + +## [1.0.4] - 2024-JAN-29 + +### Fixed +- Fixed bug where `move_portfolio_funds` params were set incorrectly + +## [1.0.3] - 2024-JAN-19 + +### Changed +- JWT generation expiry updated to 2 minutes to be consistent with Advanced Trade docs + +## [1.0.2] - 2024-JAN-10 + +### Added +- Support for files for using JSON files for API key and secret +- Improve user facing messages for common errors + +## [1.0.1] - 2024-JAN-3 + +### Added +- Support for Futures API endpoints + +## [1.0.0] - 2023-DEC-18 + +### Added +- Initial release of the Coinbase Advanced Trading API Python SDK \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..93c92dd --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 Coinbase, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..aa0c1e6 --- /dev/null +++ b/Makefile @@ -0,0 +1,9 @@ +.PHONY: format, test +format: + @echo "Formatting code..." + isort . + black . + +test: + @echo "Running tests..." + python3 -m unittest discover -v \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ef3e23 --- /dev/null +++ b/README.md @@ -0,0 +1,273 @@ +# Coinbase Advanced API Python SDK +[![PyPI version](https://badge.fury.io/py/coinbase-advanced-py.svg)](https://badge.fury.io/py/coinbase-advanced-py) +[![License](https://img.shields.io/badge/License-Apache%202.0-green.svg)](https://opensource.org/license/apache-2-0/) +[![Code Style](https://img.shields.io/badge/code_style-black-black)](https://black.readthedocs.io/en/stable/) + +Welcome to the official Coinbase Advanced API Python SDK. This python project was created to allow coders to easily plug into the [Coinbase Advanced API](https://docs.cloud.coinbase.com/advanced-trade-api/docs/welcome). +This SDK also supports easy connection to the [Coinbase Advanced Trade WebSocket API](https://docs.cloud.coinbase.com/advanced-trade-api/docs/ws-overview). + +For thorough documentation of all available functions, refer to the following link: https://coinbase.github.io/coinbase-advanced-py + +## Installation + +```bash +pip3 install coinbase-advanced-py +``` + +## Cloud API Keys + +This SDK uses the Coinbase Cloud API keys. To use this SDK, you will need to create a Coinbase Cloud API key and secret by following the instructions [here](https://docs.cloud.coinbase.com/advanced-trade-api/docs/auth#cloud-api-keys). +Make sure to save your API key and secret in a safe place. You will not be able to retrieve your secret again. + +WARNING: We do not recommend that you save your API secrets directly in your code outside of testing purposes. Best practice is to use a secrets manager and access your secrets that way. You should be careful about exposing your secrets publicly if posting code that leverages this library. + +Optional: Set your API key and secret in your environment (make sure to put these in quotation marks). For example: +```bash +export COINBASE_API_KEY="organizations/{org_id}/apiKeys/{key_id}" +export COINBASE_API_SECRET="-----BEGIN EC PRIVATE KEY-----\nYOUR PRIVATE KEY\n-----END EC PRIVATE KEY-----\n" +``` + +## REST API Client +In your code, import the RESTClient class and instantiate it: +```python +from coinbase.rest import RESTClient + +client = RESTClient() # Uses environment variables for API key and secret +``` +If you did not set your API key and secret in your environment, you can pass them in as arguments: +```python +from coinbase.rest import RESTClient + +api_key = "organizations/{org_id}/apiKeys/{key_id}" +api_secret = "-----BEGIN EC PRIVATE KEY-----\nYOUR PRIVATE KEY\n-----END EC PRIVATE KEY-----\n" + +client = RESTClient(api_key=api_key, api_secret=api_secret) +``` +After creating your API key, a json file will be downloaded to your computer. It's possible to pass in the path to this file as an argument: +```python +client = RESTClient(key_file="path/to/coinbase_cloud_api_key.json") +``` +We also support passing a file-like object as the `key_file` argument: +```python +from io import StringIO +client = RESTClient(key_file=StringIO('{"name": "key-name", "privateKey": "private-key"}')) +``` +You can also set a timeout in seconds for your REST requests like so: +```python +client = RESTClient(api_key=api_key, api_secret=api_secret, timeout=5) +``` + +### Using the REST Client + +You are able to use any of the API hooks to make calls to the Coinbase API. For example: +```python +from json import dumps + +accounts = client.get_accounts() +print(dumps(accounts, indent=2)) + +order = client.market_order_buy(client_order_id="clientOrderId", product_id="BTC-USD", quote_size="1") +print(dumps(order, indent=2)) +``` +This code calls the `get_accounts` and `market_order_buy` endpoints. + +Refer to the [Advanced API Reference](https://docs.cloud.coinbase.com/advanced-trade-api/reference) for detailed information on each exposed endpoint. +Look in the `coinbase.rest` module to see the API hooks that are exposed. + +### Passing in additional parameters +Use `kwargs` to pass in any additional parameters. For example: +```python +kwargs = { + "param1": 10, + "param2": "mock_param" +} +product = client.get_product(product_id="BTC-USD", **kwargs) +``` + +### Generic REST Calls +You can make generic REST calls using the `get`, `post`, `put`, and `delete` methods. For example: +```python +market_trades = client.get("/api/v3/brokerage/products/BTC-USD/ticker", params={"limit": 5}) + +portfolio = client.post("/api/v3/brokerage/portfolios", data={"name": "TestPortfolio"}) +``` +Here we are calling the [GetMarketTrades](https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getmarkettrades) and [CreatePortfolio](https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_createportfolio) endpoints through the generic REST functions. +Once again, the built-in way to query these through the SDK would be: +```python +market_trades = client.get_market_trades(product_id="BTC-USD", limit=5) + +portfolio = client.create_portfolio(name="TestPortfolio") +``` + +## WebSocket API Client +We offer a WebSocket API client that allows you to connect to the [Coinbase Advanced Trade WebSocket API](https://docs.cloud.coinbase.com/advanced-trade-api/docs/ws-overview). +Refer to the [Advanced Trade WebSocket Channels](https://docs.cloud.coinbase.com/advanced-trade-api/docs/ws-channels) page for detailed information on each offered channel. + +In your code, import the WSClient class and instantiate it. The WSClient requires an API key and secret to be passed in as arguments. You can also use a key file or environment variables as described in the RESTClient instructions above. + +You must specify an `on_message` function that will be called when a message is received from the WebSocket API. This function must take in a single argument, which will be the raw message received from the WebSocket API. For example: +```python +from coinbase.websocket import WSClient + +api_key = "organizations/{org_id}/apiKeys/{key_id}" +api_secret = "-----BEGIN EC PRIVATE KEY-----\nYOUR PRIVATE KEY\n-----END EC PRIVATE KEY-----\n" + +def on_message(msg): + print(msg) + +client = WSClient(api_key=api_key, api_secret=api_secret, on_message=on_message) +``` +In this example, the `on_message` function simply prints the message received from the WebSocket API. + +You can also set a `timeout` in seconds for your WebSocket connection, as well as a `max_size` in bytes for the messages received from the WebSocket API. +```python +client = WSClient(api_key=api_key, api_secret=api_secret, on_message=on_message, timeout=5, max_size=65536) # 64 KB max_size +``` +Other configurable fields are the `on_open` and `on_close` functions. If provided, these are called when the WebSocket connection is opened or closed, respectively. For example: +```python +def on_open(): + print("Connection opened!") + +client = WSClient(api_key=api_key, api_secret=api_secret, on_message=on_message, on_open=on_open) +``` + +### Using the WebSocket Client +Once you have instantiated the client, you can connect to the WebSocket API by calling the `open` method, and disconnect by calling the `close` method. +The `subscribe` method allows you to subscribe to specific channels, for specific products. Similarly, the `unsubscribe` method allows you to unsubscribe from specific channels, for specific products. For example: + +```python +# open the connection and subscribe to the ticker and heartbeat channels for BTC-USD and ETH-USD +client.open() +client.subscribe(product_ids=["BTC-USD", "ETH-USD"], channels=["ticker", "heartbeats"]) + +# wait 10 seconds +time.sleep(10) + +# unsubscribe from the ticker channel and heartbeat channels for BTC-USD and ETH-USD, and close the connection +client.unsubscribe(product_ids=["BTC-USD", "ETH-USD"], channels=["ticker", "heartbeats"]) +client.close() +``` + +We also provide channel specific methods for subscribing and unsubscribing. For example, the below code is equivalent to the example from above: +```python +client.open() +client.ticker(product_ids=["BTC-USD", "ETH-USD"]) +client.heartbeats(product_ids=["BTC-USD", "ETH-USD"]) + +# wait 10 seconds +time.sleep(10) + +client.ticker_unsubscribe(product_ids=["BTC-USD", "ETH-USD"]) +client.heartbeats_unsubscribe(product_ids=["BTC-USD", "ETH-USD"]) +client.close() +``` + +### Automatic Reconnection to the WebSocket API +The WebSocket client will automatically attempt to reconnect the WebSocket API if the connection is lost, and will resubscribe to any channels that were previously subscribed to. + +The client uses an exponential backoff algorithm to determine how long to wait before attempting to reconnect, with a maximum number of retries of 5. + +If you do not want to automatically reconnect, you can set the `retry` argument to `False` when instantiating the client. +```python +client = WSClient(api_key=api_key, api_secret=api_secret, on_message=on_message, retry=False) +``` + +### Catching WebSocket Exceptions +The WebSocket API client will raise exceptions if it encounters an error. On forced disconnects it will raise a `WSClientConnectionClosedException`, otherwise it will raise a `WSClientException`. + +NOTE: Errors on forced disconnects, or within logic in the message handler, will not be automatically raised since this will be running on its own thread. + +We provide the `sleep_with_exception_check` and `run_forever_with_exception_check` methods to allow you to catch these exceptions. `sleep_with_exception_check` will sleep for the specified number of seconds, and will check for any exception raised during that time. `run_forever_with_exception_check` will run forever, checking for exceptions every second. For example: + +```python +from coinbase.websocket import (WSClient, WSClientConnectionClosedException, + WSClientException) + +client = WSClient(api_key=api_key, api_secret=api_secret, on_message=on_message) + +try: + client.open() + client.subscribe(product_ids=["BTC-USD", "ETH-USD"], channels=["ticker", "heartbeats"]) + client.run_forever_with_exception_check() +except WSClientConnectionClosedException as e: + print("Connection closed! Retry attempts exhausted.") +except WSClientException as e: + print("Error encountered!") +``` + +This code will open the connection, subscribe to the ticker and heartbeat channels for BTC-USD and ETH-USD, and will sleep forever, checking for exceptions every second. If an exception is raised, it will be caught and handled appropriately. + +If you only want to run for 5 seconds, you can use `sleep_with_exception_check`: +```python +client.sleep_with_exception_check(sleep=5) +``` + +Note that if the automatic reconnection fails after the retry limit is reached, a `WSClientConnectionClosedException` will be raised. + +If you wish to implement your own reconnection logic, you can catch the `WSClientConnectionClosedException` and handle it appropriately. For example: +```python +client = WSClient(api_key=api_key, api_secret=api_secret, on_message=on_message, retry=False) + +def connect_and_subscribe(): + try: + client.open() + client.subscribe(product_ids=["BTC-USD", "ETH-USD"], channels=["ticker", "heartbeats"]) + client.run_forever_with_exception_check() + except WSClientConnectionClosedException as e: + print("Connection closed! Sleeping for 20 seconds before reconnecting...") + time.sleep(20) + connect_and_subscribe() +``` + +### Async WebSocket Client +The functions described above handle the asynchronous nature of WebSocket connections for you. However, if you wish to handle this yourself, you can use the `async_open`, `async_subscribe`, `async_unsubscribe`, and `async_close` methods. + +We similarly provide async channel specific methods for subscribing and unsubscribing such as `ticker_async`, `ticker_unsubscribe_async`, etc. + +## Debugging the Clients +You can enable debug logging for the REST and WebSocket clients by setting the `verbose` variable to `True` when initializing the clients. This will log useful information throughout the lifecycle of the REST request or WebSocket connection, and is highly recommended for debugging purposes. +```python +rest_client = RESTClient(api_key=api_key, api_secret=api_secret, verbose=True) + +ws_client = WSClient(api_key=api_key, api_secret=api_secret, on_message=on_message, verbose=True) +``` + +## Authentication +Authentication of Cloud API Keys is handled automatically by the SDK when making a REST request or sending a WebSocket message. + +However, if you wish to handle this yourself, you must create a JWT token and attach it to your request as detailed in the Cloud API docs [here](https://docs.cloud.coinbase.com/advanced-trade-api/docs/rest-api-auth#making-requests). Use the built in `jwt_generator` to create your JWT token. For example: +```python +from coinbase import jwt_generator + +api_key = "organizations/{org_id}/apiKeys/{key_id}" +api_secret = "-----BEGIN EC PRIVATE KEY-----\nYOUR PRIVATE KEY\n-----END EC PRIVATE KEY-----\n" + +uri = "/api/v3/brokerage/orders" + +jwt_uri = jwt_generator.format_jwt_uri("POST", uri) +jwt = jwt_generator.build_rest_jwt(jwt_uri, api_key, api_secret) +``` +This will create a JWT token for the POST `/api/v3/brokerage/orders` endpoint. Pass this JWT token in the `Authorization` header of your request as: +` +"Authorization": "Bearer " + jwt +` + +You can also generate JWTs to use with the Websocket API. These do not require passing a specific URI. For example: +```python +from coinbase import jwt_generator + +api_key = "organizations/{org_id}/apiKeys/{key_id}" +api_secret = "-----BEGIN EC PRIVATE KEY-----\nYOUR PRIVATE KEY\n-----END EC PRIVATE KEY-----\n" + +jwt = jwt_generator.build_ws_jwt(api_key, api_secret) +``` +Use this JWT to connect to the Websocket API by setting it in the "jwt" field of your subscription requests. See the docs [here](https://docs.cloud.coinbase.com/advanced-trade-api/docs/ws-overview#sending-messages-using-cloud-api-keys) for more details. + +## Changelog +For a detailed list of changes, see the [Changelog](https://github.com/coinbase/coinbase-advanced-py/blob/master/CHANGELOG.md). + +## Contributing + +If you've found a bug within this project, open an issue on this repo and add the "bug" label to it. +If you would like to request a new feature, open an issue on this repo and add the "enhancement" label to it. +Direct concerns or questions on the API to the [Advanced API Developer Forum](https://forums.coinbasecloud.dev/c/advanced-trade-api/20). diff --git a/coinbase/__init__.py b/coinbase/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/coinbase/__version__.py b/coinbase/__version__.py new file mode 100644 index 0000000..0b2f79d --- /dev/null +++ b/coinbase/__version__.py @@ -0,0 +1 @@ +__version__ = "1.1.3" diff --git a/coinbase/api_base.py b/coinbase/api_base.py new file mode 100644 index 0000000..775e7fa --- /dev/null +++ b/coinbase/api_base.py @@ -0,0 +1,59 @@ +import json +import logging +import os +from typing import IO, Optional, Union + +from coinbase.constants import API_ENV_KEY, API_SECRET_ENV_KEY + + +def get_logger(name): + logger = logging.getLogger(name) + logger.setLevel(logging.INFO) + + handler = logging.StreamHandler() + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s", "%Y-%m-%d %H:%M:%S" + ) + handler.setFormatter(formatter) + logger.addHandler(handler) + + return logger + + +class APIBase(object): + def __init__( + self, + api_key: Optional[str] = os.getenv(API_ENV_KEY), + api_secret: Optional[str] = os.getenv(API_SECRET_ENV_KEY), + key_file: Optional[Union[IO, str]] = None, + base_url=None, + timeout: Optional[int] = None, + verbose: Optional[bool] = False, + ): + if (api_key is not None or api_secret is not None) and key_file is not None: + raise Exception(f"Cannot specify both api_key and key_file in constructor") + + if key_file is not None: + try: + if isinstance(key_file, str): + with open(key_file, "r") as file: + key_json = json.load(file) + else: + key_json = json.load(key_file) + api_key = key_json["name"] + api_secret = key_json["privateKey"] + except json.JSONDecodeError as e: + raise Exception(f"Error decoding JSON: {e}") + + if api_key is None: + raise Exception( + f"Must specify env var COINBASE_API_KEY or pass api_key in constructor" + ) + if api_secret is None: + raise Exception( + f"Must specify env var COINBASE_API_SECRET or pass api_secret in constructor" + ) + self.api_key = api_key + self.api_secret = bytes(api_secret, encoding="utf8").decode("unicode_escape") + self.base_url = base_url + self.timeout = timeout diff --git a/coinbase/constants.py b/coinbase/constants.py new file mode 100644 index 0000000..f5eda87 --- /dev/null +++ b/coinbase/constants.py @@ -0,0 +1,32 @@ +from coinbase.__version__ import __version__ + +API_ENV_KEY = "COINBASE_API_KEY" +API_SECRET_ENV_KEY = "COINBASE_API_SECRET" +USER_AGENT = f"coinbase-advanced-py/{__version__}" + +# REST Constants +BASE_URL = "api.coinbase.com" +API_PREFIX = "/api/v3/brokerage" +REST_SERVICE = "retail_rest_api_proxy" + +# Websocket Constants +WS_BASE_URL = "wss://advanced-trade-ws.coinbase.com" +WS_SERVICE = "public_websocket_api" + +WS_RETRY_MAX = 5 +WS_RETRY_BASE = 5 +WS_RETRY_FACTOR = 1.5 + +# Message Types +SUBSCRIBE_MESSAGE_TYPE = "subscribe" +UNSUBSCRIBE_MESSAGE_TYPE = "unsubscribe" + +# Channels +HEARTBEATS = "heartbeats" +CANDLES = "candles" +MARKET_TRADES = "market_trades" +STATUS = "status" +TICKER = "ticker" +TICKER_BATCH = "ticker_batch" +LEVEL2 = "level2" +USER = "user" diff --git a/coinbase/jwt_generator.py b/coinbase/jwt_generator.py new file mode 100644 index 0000000..7ede411 --- /dev/null +++ b/coinbase/jwt_generator.py @@ -0,0 +1,103 @@ +import secrets +import time + +import jwt +from cryptography.hazmat.primitives import serialization + +from coinbase.constants import BASE_URL, REST_SERVICE, WS_SERVICE + + +def build_jwt(key_var, secret_var, service, uri=None) -> str: + """ + :meta private: + """ + try: + private_key_bytes = secret_var.encode("utf-8") + private_key = serialization.load_pem_private_key( + private_key_bytes, password=None + ) + except ValueError as e: + # This handles errors like incorrect key format + raise Exception( + f"{e}\n" + "Are you sure you generated your key at https://cloud.coinbase.com/access/api ?" + ) + + jwt_data = { + "sub": key_var, + "iss": "coinbase-cloud", + "nbf": int(time.time()), + "exp": int(time.time()) + 120, + "aud": [service], + } + + if uri: + jwt_data["uri"] = uri + + jwt_token = jwt.encode( + jwt_data, + private_key, + algorithm="ES256", + headers={"kid": key_var, "nonce": secrets.token_hex()}, + ) + + return jwt_token + + +def build_rest_jwt(uri, key_var, secret_var) -> str: + """ + **Build REST JWT** + __________ + + **Description:** + + Builds and returns a JWT token for connecting to the REST API. + + __________ + + Parameters: + + - **uri (str)** - Formatted URI for the endpoint (e.g. "GET api.coinbase.com/api/v3/brokerage/accounts") Can be generated using ``format_jwt_uri`` + - **key_var (str)** - The API key + - **secret_var (str)** - The API key secret + """ + return build_jwt(key_var, secret_var, REST_SERVICE, uri=uri) + + +def build_ws_jwt(key_var, secret_var) -> str: + """ + **Build WebSocket JWT** + __________ + + **Description:** + + Builds and returns a JWT token for connecting to the WebSocket API. + + __________ + + Parameters: + + - **key_var (str)** - The API key + - **secret_var (str)** - The API key secret + """ + return build_jwt(key_var, secret_var, WS_SERVICE) + + +def format_jwt_uri(method, path) -> str: + """ + **Format JWT URI** + __________ + + **Description:** + + Formats method and path into valid URI for JWT generation. + + __________ + + Parameters: + + - **method (str)** - The REST request method. E.g. GET, POST, PUT, DELETE + - **path (str)** - The path of the endpoint. E.g. "/api/v3/brokerage/accounts" + + """ + return f"{method} {BASE_URL}{path}" diff --git a/coinbase/rest/__init__.py b/coinbase/rest/__init__.py new file mode 100755 index 0000000..3d0757e --- /dev/null +++ b/coinbase/rest/__init__.py @@ -0,0 +1,72 @@ +from .rest_base import RESTBase + + +class RESTClient(RESTBase): + from .accounts import get_account, get_accounts + from .common import get_unix_time + from .convert import commit_convert_trade, create_convert_quote, get_convert_trade + from .fees import get_transaction_summary + from .futures import ( + cancel_pending_futures_sweep, + get_futures_balance_summary, + get_futures_position, + list_futures_positions, + list_futures_sweeps, + schedule_futures_sweep, + ) + from .market_data import get_candles, get_market_trades + from .orders import ( + cancel_orders, + create_order, + edit_order, + get_fills, + get_order, + limit_order_gtc, + limit_order_gtc_buy, + limit_order_gtc_sell, + limit_order_gtd, + limit_order_gtd_buy, + limit_order_gtd_sell, + list_orders, + market_order, + market_order_buy, + market_order_sell, + preview_edit_order, + preview_limit_order_gtc, + preview_limit_order_gtc_buy, + preview_limit_order_gtc_sell, + preview_limit_order_gtd, + preview_limit_order_gtd_buy, + preview_limit_order_gtd_sell, + preview_market_order, + preview_market_order_buy, + preview_market_order_sell, + preview_order, + preview_stop_limit_order_gtc, + preview_stop_limit_order_gtc_buy, + preview_stop_limit_order_gtc_sell, + preview_stop_limit_order_gtd, + preview_stop_limit_order_gtd_buy, + preview_stop_limit_order_gtd_sell, + stop_limit_order_gtc, + stop_limit_order_gtc_buy, + stop_limit_order_gtc_sell, + stop_limit_order_gtd, + stop_limit_order_gtd_buy, + stop_limit_order_gtd_sell, + ) + from .perpetuals import ( + allocate_portfolio, + get_perps_portfolio_summary, + get_perps_position, + list_perps_positions, + ) + from .portfolios import ( + create_portfolio, + delete_portfolio, + edit_portfolio, + get_portfolio_breakdown, + get_portfolios, + move_portfolio_funds, + ) + from .products import get_best_bid_ask, get_product, get_product_book, get_products diff --git a/coinbase/rest/accounts.py b/coinbase/rest/accounts.py new file mode 100644 index 0000000..f6b233b --- /dev/null +++ b/coinbase/rest/accounts.py @@ -0,0 +1,50 @@ +from typing import Any, Dict, Optional + +from coinbase.constants import API_PREFIX + + +def get_accounts( + self, limit: Optional[int] = None, cursor: Optional[str] = None, **kwargs +) -> Dict[str, Any]: + """ + **List Accounts** + _________________ + [GET] https://api.coinbase.com/api/v3/brokerage/accounts + + __________ + + **Description:** + + Get a list of authenticated accounts for the current user. + + __________ + + **Read more on the official documentation:** `List Accounts `_ + + """ + endpoint = f"{API_PREFIX}/accounts" + params = {"limit": limit, "cursor": cursor} + + return self.get(endpoint, params=params, **kwargs) + + +def get_account(self, account_uuid: str, **kwargs) -> Dict[str, Any]: + """ + + **Get Account** + _______________ + [GET] https://api.coinbase.com/api/v3/brokerage/accounts/{account_uuid} + + __________ + + **Description:** + + Get a list of information about an account, given an account UUID. + + __________ + + **Read more on the official documentation:** `Get Account `_ + """ + endpoint = f"{API_PREFIX}/accounts/{account_uuid}" + + return self.get(endpoint, **kwargs) diff --git a/coinbase/rest/common.py b/coinbase/rest/common.py new file mode 100644 index 0000000..3a4e2a7 --- /dev/null +++ b/coinbase/rest/common.py @@ -0,0 +1,24 @@ +from typing import Any, Dict + +from coinbase.constants import API_PREFIX + + +def get_unix_time(self, **kwargs) -> Dict[str, Any]: + """ + **Get UNIX Time** + _________________ + [GET] https://api.coinbase.com/api/v3/brokerage/time + + __________ + + **Description:** + + Get the current time from the Coinbase Advanced API. + + __________ + + **Read more on the official documentation:** `Get UNIX Time `_ + """ + endpoint = f"{API_PREFIX}/time" + + return self.get(endpoint, **kwargs) diff --git a/coinbase/rest/convert.py b/coinbase/rest/convert.py new file mode 100644 index 0000000..a3b3ccd --- /dev/null +++ b/coinbase/rest/convert.py @@ -0,0 +1,110 @@ +from typing import Any, Dict, Optional + +from coinbase.constants import API_PREFIX + + +def create_convert_quote( + self, + from_account: str, + to_account: str, + amount: str, + user_incentive_id: Optional[str] = None, + code_val: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Create Convert Quote** + ________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/convert/quote + + __________ + + **Description:** + + Create a convert quote with a specified source currency, target currency, and amount. + + __________ + + **Read more on the official documentation:** `Create Convert Quote `_ + """ + endpoint = f"{API_PREFIX}/convert/quote" + + data = { + "from_account": from_account, + "to_account": to_account, + "amount": amount, + } + + trade_incentive_metadata = { + "user_incentive_id": user_incentive_id, + "code_val": code_val, + } + filtered_trade_incentive_metadata = { + key: value + for key, value in trade_incentive_metadata.items() + if value is not None + } + + if filtered_trade_incentive_metadata: + data["trade_incentive_metadata"] = filtered_trade_incentive_metadata + + return self.post(endpoint, data=data, **kwargs) + + +def get_convert_trade( + self, trade_id: str, from_account: str, to_account: str, **kwargs +) -> Dict[str, Any]: + """ + **Get Convert Trade** + _____________________ + + [GET] https://api.coinbase.com/api/v3/brokerage/convert/trade/{trade_id} + + __________ + + **Description:** + + Gets a list of information about a convert trade with a specified trade ID, source currency, and target currency. + + __________ + + **Read more on the official documentation:** `Get Convert Trade `_ + """ + endpoint = f"{API_PREFIX}/convert/trade/{trade_id}" + + params = { + "from_account": from_account, + "to_account": to_account, + } + + return self.get(endpoint, params=params, **kwargs) + + +def commit_convert_trade( + self, trade_id: str, from_account: str, to_account: str, **kwargs +) -> Dict[str, Any]: + """ + **Commit Convert Trade** + ________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/convert/trade/{trade_id} + + __________ + + **Description:** + + Commits a convert trade with a specified trade ID, source currency, and target currency. + + __________ + + **Read more on the official documentation:** `Commit Convert Trade `_ + """ + endpoint = f"{API_PREFIX}/convert/trade/{trade_id}" + + data = { + "from_account": from_account, + "to_account": to_account, + } + + return self.post(endpoint, data=data, **kwargs) diff --git a/coinbase/rest/fees.py b/coinbase/rest/fees.py new file mode 100644 index 0000000..5b65e00 --- /dev/null +++ b/coinbase/rest/fees.py @@ -0,0 +1,35 @@ +from typing import Any, Dict, Optional + +from coinbase.constants import API_PREFIX + + +def get_transaction_summary( + self, + product_type: Optional[str] = None, + contract_expiry_type: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Get Transactions Summary** + _____________________________ + + [GET] https://api.coinbase.com/api/v3/brokerage/transaction_summary + + __________ + + **Description:** + + Get a summary of transactions with fee tiers, total volume, and fees. + + __________ + + **Read more on the official documentation:** `Create Convert Quote `_ + """ + endpoint = f"{API_PREFIX}/transaction_summary" + + params = { + "product_type": product_type, + "contract_expiry_type": contract_expiry_type, + } + + return self.get(endpoint, params=params, **kwargs) diff --git a/coinbase/rest/futures.py b/coinbase/rest/futures.py new file mode 100644 index 0000000..904fa16 --- /dev/null +++ b/coinbase/rest/futures.py @@ -0,0 +1,143 @@ +from typing import Any, Dict + +from coinbase.constants import API_PREFIX + + +def get_futures_balance_summary(self, **kwargs) -> Dict[str, Any]: + """ + **Get Futures Balance Summary** + _______________________________ + + [GET] https://api.coinbase.com/api/v3/brokerage/cfm/balance_summary + + __________ + + **Description:** + + Get information on your balances related to `Coinbase Financial Markets `_ (CFM) futures trading. + + __________ + + **Read more on the official documentation:** `Get Futures Balance Summary + `_ + """ + endpoint = f"{API_PREFIX}/cfm/balance_summary" + + return self.get(endpoint, **kwargs) + + +def list_futures_positions(self, **kwargs) -> Dict[str, Any]: + """ + **List Futures Positions** + __________________________ + + [GET] https://api.coinbase.com/api/v3/brokerage/cfm/positions + + __________ + + **Description:** + + Get a list of all open positions in CFM futures products. + + __________ + + **Read more on the official documentation:** `List Futures Positions + `_ + """ + endpoint = f"{API_PREFIX}/cfm/positions" + + return self.get(endpoint, **kwargs) + + +def get_futures_position(self, product_id: str, **kwargs) -> Dict[str, Any]: + """ + **Get Futures Position** + _________________________ + + [GET] https://api.coinbase.com/api/v3/brokerage/cfm/positions/{product_id} + + __________ + + **Description:** + + Get the position of a specific CFM futures product. + + __________ + + **Read more on the official documentation:** `Get Futures Position + `_ + """ + endpoint = f"{API_PREFIX}/cfm/positions/{product_id}" + + return self.get(endpoint, **kwargs) + + +def schedule_futures_sweep(self, usd_amount: str, **kwargs) -> Dict[str, Any]: + """ + **Schedule Futures Sweep** + __________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/cfm/sweeps/schedule + + __________ + + **Description:** + + Schedule a sweep of funds from your CFTC-regulated futures account to your Coinbase Inc. USD Spot wallet. + + __________ + + **Read more on the official documentation:** `Schedule Futures Sweep + `_ + """ + endpoint = f"{API_PREFIX}/cfm/sweeps/schedule" + + data = {"usd_amount": usd_amount} + + return self.post(endpoint, data=data, **kwargs) + + +def list_futures_sweeps(self, **kwargs) -> Dict[str, Any]: + """ + **List Futures Sweeps** + _______________________ + + [GET] https://api.coinbase.com/api/v3/brokerage/cfm/sweeps + + __________ + + **Description:** + + Get information on your pending and/or processing requests to sweep funds from your CFTC-regulated futures account to your Coinbase Inc. USD Spot wallet. + + __________ + + **Read more on the official documentation:** `List Futures Sweeps + `_ + """ + endpoint = f"{API_PREFIX}/cfm/sweeps" + + return self.get(endpoint, **kwargs) + + +def cancel_pending_futures_sweep(self, **kwargs) -> Dict[str, Any]: + """ + **Cancel Pending Futures Sweep** + ________________________________ + + [DELETE] https://api.coinbase.com/api/v3/brokerage/cfm/sweeps + + __________ + + **Description:** + + Cancel your pending sweep of funds from your CFTC-regulated futures account to your Coinbase Inc. USD Spot wallet. + + __________ + + **Read more on the official documentation:** `Cancel Pending Futures Sweep + `_ + """ + endpoint = f"{API_PREFIX}/cfm/sweeps" + + return self.delete(endpoint, **kwargs) diff --git a/coinbase/rest/market_data.py b/coinbase/rest/market_data.py new file mode 100644 index 0000000..eef5b9a --- /dev/null +++ b/coinbase/rest/market_data.py @@ -0,0 +1,66 @@ +from typing import Any, Dict, Optional + +from coinbase.constants import API_PREFIX + + +def get_candles( + self, product_id: str, start: str, end: str, granularity: str, **kwargs +) -> Dict[str, Any]: + """ + **Get Product Candles** + __________ + + [GET] https://api.coinbase.com/api/v3/brokerage/products/{product_id}/candles + + __________ + + **Description:** + + Get rates for a single product by product ID, grouped in buckets. + + __________ + + **Read more on the official documentation:** `Get Product Candles + `_ + """ + endpoint = f"{API_PREFIX}/products/{product_id}/candles" + + params = { + "start": start, + "end": end, + "granularity": granularity, + } + + return self.get(endpoint, params=params, **kwargs) + + +def get_market_trades( + self, + product_id: str, + limit: int, + start: Optional[str] = None, + end: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Get Market Trades** + _____________________ + + [GET] https://api.coinbase.com/api/v3/brokerage/products/{product_id}/ticker + + __________ + + **Description:** + + Get snapshot information, by product ID, about the last trades (ticks), best bid/ask, and 24h volume. + + __________ + + **Read more on the official documentation:** `Get Market Trades + `_ + """ + endpoint = f"{API_PREFIX}/products/{product_id}/ticker" + + params = {"limit": limit, "start": start, "end": end} + + return self.get(endpoint, params=params, **kwargs) diff --git a/coinbase/rest/orders.py b/coinbase/rest/orders.py new file mode 100644 index 0000000..188f85a --- /dev/null +++ b/coinbase/rest/orders.py @@ -0,0 +1,1842 @@ +from typing import Any, Dict, List, Optional + +from coinbase.constants import API_PREFIX + + +def create_order( + self, + client_order_id: str, + product_id: str, + side: str, + order_configuration, + self_trade_prevention_id: Optional[str] = None, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + retail_portfolio_id: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Create Order** + ________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders + + __________ + + **Description:** + + Create an order with a specified ``product_id`` (asset-pair), ``side`` (buy/sell), etc. + + __________ + + **Read more on the official documentation:** `Create Order + `_ + """ + endpoint = f"{API_PREFIX}/orders" + + data = { + "client_order_id": client_order_id, + "product_id": product_id, + "side": side, + "order_configuration": order_configuration, + "self_trade_prevention_id": self_trade_prevention_id, + "leverage": leverage, + "margin_type": margin_type, + "retail_portfolio_id": retail_portfolio_id, + } + + return self.post(endpoint, data=data, **kwargs) + + +# Market orders +def market_order( + self, + client_order_id: str, + product_id: str, + side: str, + quote_size: Optional[str] = None, + base_size: Optional[str] = None, + self_trade_prevention_id: Optional[str] = None, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + retail_portfolio_id: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Market Order** + ________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders + + __________ + + **Description:** + + Place a market order to BUY or SELL the desired product at the given market price. If you wish to purchase the + product, provide a quote_size and if you wish to sell the product, provide a base_size. + + __________ + + **Read more on the official documentation:** `Create Order + `_ + """ + + market_market_ioc = {"quote_size": quote_size, "base_size": base_size} + filtered_market_market_ioc = { + key: value for key, value in market_market_ioc.items() if value is not None + } + + order_configuration = {"market_market_ioc": filtered_market_market_ioc} + + return create_order( + self, + client_order_id, + product_id, + side, + order_configuration, + self_trade_prevention_id=self_trade_prevention_id, + leverage=leverage, + margin_type=margin_type, + retail_portfolio_id=retail_portfolio_id, + **kwargs, + ) + + +def market_order_buy( + self, + client_order_id: str, + product_id: str, + quote_size: str, + self_trade_prevention_id: Optional[str] = None, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + retail_portfolio_id: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Create Market Order Buy** + ____________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders + + __________ + + **Description:** + + Place a market order to BUY the desired product at the given market price. + + __________ + + **Read more on the official documentation:** `Create Order + `_ + """ + return market_order( + self, + client_order_id, + product_id, + "BUY", + quote_size=quote_size, + self_trade_prevention_id=self_trade_prevention_id, + leverage=leverage, + margin_type=margin_type, + retail_portfolio_id=retail_portfolio_id, + **kwargs, + ) + + +def market_order_sell( + self, + client_order_id: str, + product_id: str, + base_size: str, + self_trade_prevention_id: Optional[str] = None, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + retail_portfolio_id: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Create Market Order Sell** + _____________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders + + __________ + + **Description:** + + Place a market order to SELL the desired product at the given market price. + + __________ + + **Read more on the official documentation:** `Create Order + `_ + """ + return market_order( + self, + client_order_id, + product_id, + "SELL", + base_size=base_size, + self_trade_prevention_id=self_trade_prevention_id, + leverage=leverage, + margin_type=margin_type, + retail_portfolio_id=retail_portfolio_id, + **kwargs, + ) + + +# Limit GTC orders +def limit_order_gtc( + self, + client_order_id: str, + product_id: str, + side: str, + base_size: str, + limit_price: str, + post_only: bool = False, + self_trade_prevention_id: Optional[str] = None, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + retail_portfolio_id: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Limit Order GTC** + ___________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders + + __________ + + **Description:** + + Place a Limit Order with a GTC time-in-force policy. Provide the base_size (quantity of your base currency to + spend) as well as a limit_price that indicates the maximum price at which the order should be filled. + + __________ + + **Read more on the official documentation:** `Create Order + `_ + """ + order_configuration = { + "limit_limit_gtc": { + "base_size": base_size, + "limit_price": limit_price, + "post_only": post_only, + } + } + + return create_order( + self, + client_order_id, + product_id, + side, + order_configuration, + self_trade_prevention_id=self_trade_prevention_id, + leverage=leverage, + margin_type=margin_type, + retail_portfolio_id=retail_portfolio_id, + **kwargs, + ) + + +def limit_order_gtc_buy( + self, + client_order_id: str, + product_id: str, + base_size: str, + limit_price: str, + post_only: bool = False, + self_trade_prevention_id: Optional[str] = None, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + retail_portfolio_id: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Limit Order GTC Buy** + _______________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders + + __________ + + **Description:** + + Place a BUY Limit Order with a GTC time-in-force policy. Provide the base_size (quantity of your base currency to + spend) as well as a limit_price that indicates the maximum price at which the order should be filled. + + __________ + + **Read more on the official documentation:** `Create Order + `_ + """ + return limit_order_gtc( + self, + client_order_id, + product_id, + "BUY", + base_size=base_size, + limit_price=limit_price, + post_only=post_only, + self_trade_prevention_id=self_trade_prevention_id, + leverage=leverage, + margin_type=margin_type, + retail_portfolio_id=retail_portfolio_id, + **kwargs, + ) + + +def limit_order_gtc_sell( + self, + client_order_id: str, + product_id: str, + base_size: str, + limit_price: str, + post_only: bool = False, + self_trade_prevention_id: Optional[str] = None, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + retail_portfolio_id: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Limit Order GTC Sell** + ________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders + + __________ + + **Description:** + + Place a SELL Limit Order with a GTC time-in-force policy. Provide the base_size (quantity of your base currency to + spend) as well as a limit_price that indicates the maximum price at which the order should be filled. + + __________ + + **Read more on the official documentation:** `Create Order + `_ + """ + return limit_order_gtc( + self, + client_order_id, + product_id, + "SELL", + base_size=base_size, + limit_price=limit_price, + post_only=post_only, + self_trade_prevention_id=self_trade_prevention_id, + leverage=leverage, + margin_type=margin_type, + retail_portfolio_id=retail_portfolio_id, + **kwargs, + ) + + +# Limit GTD orders +def limit_order_gtd( + self, + client_order_id: str, + product_id: str, + side: str, + base_size: str, + limit_price: str, + end_time: str, + post_only: bool = False, + self_trade_prevention_id: Optional[str] = None, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + retail_portfolio_id: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Limit Order GTD** + ___________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders + + __________ + + **Description:** + + Place a Limit Order with a GTD time-in-force policy. Unlike a Limit Order with a GTC time-in-force policy, + this order type requires an end-time that indicates when this order should expire. + + __________ + + **Read more on the official documentation:** `Create Order + `_ + """ + order_configuration = { + "limit_limit_gtd": { + "base_size": base_size, + "limit_price": limit_price, + "end_time": end_time, + "post_only": post_only, + } + } + + return create_order( + self, + client_order_id, + product_id, + side, + order_configuration, + self_trade_prevention_id=self_trade_prevention_id, + leverage=leverage, + margin_type=margin_type, + retail_portfolio_id=retail_portfolio_id, + **kwargs, + ) + + +def limit_order_gtd_buy( + self, + client_order_id: str, + product_id: str, + base_size: str, + limit_price: str, + end_time: str, + post_only: bool = False, + self_trade_prevention_id: Optional[str] = None, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + retail_portfolio_id: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Limit Order GTD Buy** + _______________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders + + __________ + + **Description:** + + Place a BUY Limit Order with a GTD time-in-force policy. Unlike a Limit Order with a GTC time-in-force policy, + this order type requires an end-time that indicates when this order should expire. + + __________ + + **Read more on the official documentation:** `Create Order + `_ + """ + return limit_order_gtd( + self, + client_order_id, + product_id, + "BUY", + base_size=base_size, + limit_price=limit_price, + end_time=end_time, + post_only=post_only, + self_trade_prevention_id=self_trade_prevention_id, + leverage=leverage, + margin_type=margin_type, + retail_portfolio_id=retail_portfolio_id, + **kwargs, + ) + + +def limit_order_gtd_sell( + self, + client_order_id: str, + product_id: str, + base_size: str, + limit_price: str, + end_time: str, + post_only: bool = False, + self_trade_prevention_id: Optional[str] = None, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + retail_portfolio_id: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Limit Order GTD Sell** + ________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders + + __________ + + **Description:** + + Place a SELL Limit Order with a GTD time-in-force policy. Unlike a Limit Order with a GTC time-in-force policy, + this order type requires an end-time that indicates when this order should expire. + + __________ + + **Read more on the official documentation:** `Create Order + `_ + """ + return limit_order_gtd( + self, + client_order_id, + product_id, + "SELL", + base_size=base_size, + limit_price=limit_price, + end_time=end_time, + post_only=post_only, + self_trade_prevention_id=self_trade_prevention_id, + leverage=leverage, + margin_type=margin_type, + retail_portfolio_id=retail_portfolio_id, + **kwargs, + ) + + +# Stop-Limit GTC orders +def stop_limit_order_gtc( + self, + client_order_id: str, + product_id: str, + side: str, + base_size: str, + limit_price: str, + stop_price: str, + stop_direction: str, + self_trade_prevention_id: Optional[str] = None, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + retail_portfolio_id: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Stop-Limit Order GTC** + ________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders + + __________ + + **Description:** + + Place a Stop Limit order with a GTC time-in-force policy. Stop orders become active and wait to trigger based on + the movement of the last trade price. The last trade price is the last price at which an order was filled. + + __________ + + **Read more on the official documentation:** `Create Order + `_ + """ + order_configuration = { + "stop_limit_stop_limit_gtc": { + "base_size": base_size, + "limit_price": limit_price, + "stop_price": stop_price, + "stop_direction": stop_direction, + } + } + + return create_order( + self, + client_order_id, + product_id, + side, + order_configuration, + self_trade_prevention_id=self_trade_prevention_id, + leverage=leverage, + margin_type=margin_type, + retail_portfolio_id=retail_portfolio_id, + **kwargs, + ) + + +def stop_limit_order_gtc_buy( + self, + client_order_id: str, + product_id: str, + base_size: str, + limit_price: str, + stop_price: str, + stop_direction: str, + self_trade_prevention_id: Optional[str] = None, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + retail_portfolio_id: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Stop-Limit Order GTC Buy** + ____________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders + + __________ + + **Description:** + + Place a BUY Stop Limit order with a GTC time-in-force policy. Stop orders become active and wait to trigger based on + the movement of the last trade price. The last trade price is the last price at which an order was filled. + + __________ + + **Read more on the official documentation:** `Create Order + `_ + """ + return stop_limit_order_gtc( + self, + client_order_id, + product_id, + "BUY", + base_size=base_size, + limit_price=limit_price, + stop_price=stop_price, + stop_direction=stop_direction, + self_trade_prevention_id=self_trade_prevention_id, + leverage=leverage, + margin_type=margin_type, + retail_portfolio_id=retail_portfolio_id, + **kwargs, + ) + + +def stop_limit_order_gtc_sell( + self, + client_order_id: str, + product_id: str, + base_size: str, + limit_price: str, + stop_price: str, + stop_direction: str, + self_trade_prevention_id: Optional[str] = None, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + retail_portfolio_id: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Stop-Limit Order GTC Sell** + _____________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders + + __________ + + **Description:** + + Place a SELL Stop Limit order with a GTC time-in-force policy. Stop orders become active and wait to trigger based on + the movement of the last trade price. The last trade price is the last price at which an order was filled. + + __________ + + **Read more on the official documentation:** `Create Order + `_ + """ + return stop_limit_order_gtc( + self, + client_order_id, + product_id, + "SELL", + base_size=base_size, + limit_price=limit_price, + stop_price=stop_price, + stop_direction=stop_direction, + self_trade_prevention_id=self_trade_prevention_id, + leverage=leverage, + margin_type=margin_type, + retail_portfolio_id=retail_portfolio_id, + **kwargs, + ) + + +# Stop-Limit GTD orders +def stop_limit_order_gtd( + self, + client_order_id: str, + product_id: str, + side: str, + base_size: str, + limit_price: str, + stop_price: str, + end_time: str, + stop_direction: str, + self_trade_prevention_id: Optional[str] = None, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + retail_portfolio_id: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Stop-Limit Order GTD** + ________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders + + __________ + + **Description:** + + Place a Stop Limit order with a GTD time-in-force policy. Stop orders become active and wait to trigger based on + the movement of the last trade price. The last trade price is the last price at which an order was filled. + + __________ + + **Read more on the official documentation:** `Create Order + `_ + """ + order_configuration = { + "stop_limit_stop_limit_gtd": { + "base_size": base_size, + "limit_price": limit_price, + "stop_price": stop_price, + "end_time": end_time, + "stop_direction": stop_direction, + } + } + + return create_order( + self, + client_order_id, + product_id, + side, + order_configuration, + self_trade_prevention_id=self_trade_prevention_id, + leverage=leverage, + margin_type=margin_type, + retail_portfolio_id=retail_portfolio_id, + **kwargs, + ) + + +def stop_limit_order_gtd_buy( + self, + client_order_id: str, + product_id: str, + base_size: str, + limit_price: str, + stop_price: str, + end_time: str, + stop_direction: str, + self_trade_prevention_id: Optional[str] = None, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + retail_portfolio_id: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Stop-Limit Order GTD Buy** + ____________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders + + __________ + + **Description:** + + Place a BUY Stop Limit order with a GTD time-in-force policy. Stop orders become active and wait to trigger based on + the movement of the last trade price. The last trade price is the last price at which an order was filled. + + __________ + + **Read more on the official documentation:** `Create Order + `_ + """ + return stop_limit_order_gtd( + self, + client_order_id, + product_id, + "BUY", + base_size=base_size, + limit_price=limit_price, + stop_price=stop_price, + end_time=end_time, + stop_direction=stop_direction, + self_trade_prevention_id=self_trade_prevention_id, + leverage=leverage, + margin_type=margin_type, + retail_portfolio_id=retail_portfolio_id, + **kwargs, + ) + + +def stop_limit_order_gtd_sell( + self, + client_order_id: str, + product_id: str, + base_size: str, + limit_price: str, + stop_price: str, + end_time: str, + stop_direction: str, + self_trade_prevention_id: Optional[str] = None, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + retail_portfolio_id: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Stop-Limit Order GTD Sell** + _____________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders + + __________ + + **Description:** + + Place a SELL Stop Limit order with a GTD time-in-force policy. Stop orders become active and wait to trigger based on + the movement of the last trade price. The last trade price is the last price at which an order was filled. + + __________ + + **Read more on the official documentation:** `Create Order + `_ + """ + return stop_limit_order_gtd( + self, + client_order_id, + product_id, + "SELL", + base_size=base_size, + limit_price=limit_price, + stop_price=stop_price, + end_time=end_time, + stop_direction=stop_direction, + self_trade_prevention_id=self_trade_prevention_id, + leverage=leverage, + margin_type=margin_type, + retail_portfolio_id=retail_portfolio_id, + **kwargs, + ) + + +def get_order(self, order_id: str, **kwargs) -> Dict[str, Any]: + """ + **Get Order** + _____________ + + [GET] https://api.coinbase.com/api/v3/brokerage/orders/historical/{order_id} + + __________ + + **Description:** + + Get a single order by order ID. + + __________ + + **Read more on the official documentation:** `Get Order + `_ + """ + endpoint = f"{API_PREFIX}/orders/historical/{order_id}" + + return self.get(endpoint, **kwargs) + + +def list_orders( + self, + product_id: Optional[str] = None, + order_status: Optional[List[str]] = None, + limit: Optional[int] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + order_type: Optional[str] = None, + order_side: Optional[str] = None, + cursor: Optional[str] = None, + product_type: Optional[str] = None, + order_placement_source: Optional[str] = None, + contract_expiry_type: Optional[str] = None, + asset_filters: Optional[List[str]] = None, + retail_portfolio_id: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **List Orders** + _______________ + + [GET] https://api.coinbase.com/api/v3/brokerage/orders/historical/batch + + __________ + + **Description:** + + Get a list of orders filtered by optional query parameters (``product_id``, ``order_status``, etc). + + __________ + + **Read more on the official documentation:** `List Orders + `_ + """ + endpoint = f"{API_PREFIX}/orders/historical/batch" + params = { + "product_id": product_id, + "order_status": order_status, + "limit": limit, + "start_date": start_date, + "end_date": end_date, + "order_type": order_type, + "order_side": order_side, + "cursor": cursor, + "product_type": product_type, + "order_placement_source": order_placement_source, + "contract_expiry_type": contract_expiry_type, + "asset_filters": asset_filters, + "retail_portfolio_id": retail_portfolio_id, + } + + return self.get(endpoint, params=params, **kwargs) + + +def get_fills( + self, + order_id: Optional[str] = None, + product_id: Optional[str] = None, + start_sequence_timestamp: Optional[str] = None, + end_sequence_timestamp: Optional[str] = None, + limit: Optional[int] = None, + cursor: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **List Fills** + ______________ + + [GET] https://api.coinbase.com/api/v3/brokerage/orders/historical/fills + + __________ + + **Description:** + + Get a list of fills filtered by optional query parameters (``product_id``, ``order_id``, etc). + + __________ + + **Read more on the official documentation:** `List Fills + `_ + """ + endpoint = f"{API_PREFIX}/orders/historical/fills" + params = { + "order_id": order_id, + "product_id": product_id, + "start_sequence_timestamp": start_sequence_timestamp, + "end_sequence_timestamp": end_sequence_timestamp, + "limit": limit, + "cursor": cursor, + } + + return self.get(endpoint, params=params, **kwargs) + + +def edit_order( + self, + order_id: str, + size: Optional[str] = None, + price: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Edit Order** + ______________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/edit + + __________ + + **Description:** + + Edit an order with a specified new ``size``, or new ``price``. Only limit order types, with time in force type of good-till-cancelled can be edited. + + __________ + + **Read more on the official documentation:** `Edit Order + `_ + """ + endpoint = f"{API_PREFIX}/orders/edit" + data = { + "order_id": order_id, + "size": size, + "price": price, + } + + return self.post(endpoint, data=data, **kwargs) + + +def preview_edit_order( + self, + order_id: str, + size: Optional[str] = None, + price: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Preview Edit Order** + ______________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/edit_preview + + __________ + + **Description:** + + Simulate an edit order request with a specified new ``size``, or new ``price``, to preview the result of an edit. Only limit order types, with time in force type of good-till-cancelled can be edited. + + __________ + + **Read more on the official documentation:** `Edit Order Preview + `_ + """ + endpoint = f"{API_PREFIX}/orders/edit_preview" + data = { + "order_id": order_id, + "size": size, + "price": price, + } + + return self.post(endpoint, data=data, **kwargs) + + +def cancel_orders(self, order_ids: List[str], **kwargs) -> Dict[str, Any]: + """ + **Cancel Orders** + _________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/batch_cancel + + __________ + + **Description:** + + Initiate cancel requests for one or more orders. + + __________ + + **Read more on the official documentation:** `Cancel Orders + `_ + """ + endpoint = f"{API_PREFIX}/orders/batch_cancel" + data = { + "order_ids": order_ids, + } + + return self.post(endpoint, data=data, **kwargs) + + +def preview_order( + self, + product_id: str, + side: str, + order_configuration, + commission_rate: Optional[str] = None, + is_max: Optional[bool] = False, + tradable_balance: Optional[str] = None, + skip_fcm_risk_check: Optional[bool] = False, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Preview Order** + _________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/preview + + __________ + + **Description:** + + Preview the results of an order request before sending. + + __________ + + **Read more on the official documentation:** `Preview Order + `_ + """ + endpoint = f"{API_PREFIX}/orders/preview" + + if commission_rate: + commission_rate = {"value": commission_rate} + + data = { + "product_id": product_id, + "side": side, + "order_configuration": order_configuration, + "commission_rate": commission_rate, + "is_max": is_max, + "tradable_balance": tradable_balance, + "skip_fcm_risk_check": skip_fcm_risk_check, + "leverage": leverage, + "margin_type": margin_type, + } + + return self.post(endpoint, data=data, **kwargs) + + +# Preview market orders +def preview_market_order( + self, + product_id: str, + side: str, + quote_size: Optional[str] = None, + base_size: Optional[str] = None, + commission_rate: Optional[str] = None, + is_max: Optional[bool] = False, + tradable_balance: Optional[str] = None, + skip_fcm_risk_check: Optional[bool] = False, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Preview Market Order** + ________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/preview + + __________ + + **Description:** + + Preview the results of a market order request before sending. + + __________ + + **Read more on the official documentation:** `Preview Order + `_ + """ + + market_market_ioc = {"quote_size": quote_size, "base_size": base_size} + filtered_market_market_ioc = { + key: value for key, value in market_market_ioc.items() if value is not None + } + + order_configuration = {"market_market_ioc": filtered_market_market_ioc} + + return preview_order( + self, + product_id, + side, + order_configuration, + commission_rate=commission_rate, + is_max=is_max, + tradable_balance=tradable_balance, + skip_fcm_risk_check=skip_fcm_risk_check, + leverage=leverage, + margin_type=margin_type, + **kwargs, + ) + + +def preview_market_order_buy( + self, + product_id: str, + quote_size: Optional[str] = None, + commission_rate: Optional[str] = None, + is_max: Optional[bool] = False, + tradable_balance: Optional[str] = None, + skip_fcm_risk_check: Optional[bool] = False, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Preview Market Buy Order** + ____________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/preview + + __________ + + **Description:** + + Preview the results of a market order buy request before sending. + + __________ + + **Read more on the official documentation:** `Preview Order + `_ + """ + return preview_market_order( + self, + product_id, + "BUY", + quote_size=quote_size, + commission_rate=commission_rate, + is_max=is_max, + tradable_balance=tradable_balance, + skip_fcm_risk_check=skip_fcm_risk_check, + leverage=leverage, + margin_type=margin_type, + **kwargs, + ) + + +def preview_market_order_sell( + self, + product_id: str, + base_size: str, + commission_rate: Optional[str] = None, + is_max: Optional[bool] = False, + tradable_balance: Optional[str] = None, + skip_fcm_risk_check: Optional[bool] = False, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Preview Market Sell Order** + _____________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/preview + + __________ + + **Description:** + + Preview the results of a market order sell request before sending. + + __________ + + **Read more on the official documentation:** `Preview Order + `_ + """ + return preview_market_order( + self, + product_id, + "SELL", + base_size=base_size, + commission_rate=commission_rate, + is_max=is_max, + tradable_balance=tradable_balance, + skip_fcm_risk_check=skip_fcm_risk_check, + leverage=leverage, + margin_type=margin_type, + **kwargs, + ) + + +# Preview Limit GTC orders +def preview_limit_order_gtc( + self, + product_id: str, + side: str, + base_size: str, + limit_price: str, + post_only: bool = False, + commission_rate: Optional[str] = None, + is_max: Optional[bool] = False, + tradable_balance: Optional[str] = None, + skip_fcm_risk_check: Optional[bool] = False, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Preview Limit Order GTC** + ___________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/preview + + __________ + + **Description:** + + Preview the results of a limit order GTC request before sending. + + __________ + + **Read more on the official documentation:** `Preview Order + `_ + """ + order_configuration = { + "limit_limit_gtc": { + "base_size": base_size, + "limit_price": limit_price, + "post_only": post_only, + } + } + + return preview_order( + self, + product_id, + side, + order_configuration, + commission_rate=commission_rate, + is_max=is_max, + tradable_balance=tradable_balance, + skip_fcm_risk_check=skip_fcm_risk_check, + leverage=leverage, + margin_type=margin_type, + **kwargs, + ) + + +def preview_limit_order_gtc_buy( + self, + product_id: str, + base_size: str, + limit_price: str, + post_only: bool = False, + commission_rate: Optional[str] = None, + is_max: Optional[bool] = False, + tradable_balance: Optional[str] = None, + skip_fcm_risk_check: Optional[bool] = False, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Preview Limit Order GTC Buy** + _______________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/preview + + __________ + + **Description:** + + Preview the results of a limit order GTC buy request before sending. + + __________ + + **Read more on the official documentation:** `Preview Order + `_ + """ + return preview_limit_order_gtc( + self, + product_id, + "BUY", + base_size=base_size, + limit_price=limit_price, + post_only=post_only, + commission_rate=commission_rate, + is_max=is_max, + tradable_balance=tradable_balance, + skip_fcm_risk_check=skip_fcm_risk_check, + leverage=leverage, + margin_type=margin_type, + **kwargs, + ) + + +def preview_limit_order_gtc_sell( + self, + product_id: str, + base_size: str, + limit_price: str, + post_only: bool = False, + commission_rate: Optional[str] = None, + is_max: Optional[bool] = False, + tradable_balance: Optional[str] = None, + skip_fcm_risk_check: Optional[bool] = False, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Preview Limit Order GTC Sell** + ________________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/preview + + __________ + + **Description:** + + Preview the results of a limit order GTC sell request before sending. + + __________ + + **Read more on the official documentation:** `Preview Order + `_ + """ + return preview_limit_order_gtc( + self, + product_id, + "SELL", + base_size=base_size, + limit_price=limit_price, + post_only=post_only, + commission_rate=commission_rate, + is_max=is_max, + tradable_balance=tradable_balance, + skip_fcm_risk_check=skip_fcm_risk_check, + leverage=leverage, + margin_type=margin_type, + **kwargs, + ) + + +# Preview Limit GTD orders +def preview_limit_order_gtd( + self, + product_id: str, + side: str, + base_size: str, + limit_price: str, + end_time: str, + post_only: bool = False, + commission_rate: Optional[str] = None, + is_max: Optional[bool] = False, + tradable_balance: Optional[str] = None, + skip_fcm_risk_check: Optional[bool] = False, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Preview Limit Order GTD** + ___________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/preview + + __________ + + **Description:** + + Preview the results of a limit order GTD request before sending. + + __________ + + **Read more on the official documentation:** `Preview Order + `_ + """ + order_configuration = { + "limit_limit_gtd": { + "base_size": base_size, + "limit_price": limit_price, + "end_time": end_time, + "post_only": post_only, + } + } + + return preview_order( + self, + product_id, + side, + order_configuration, + commission_rate=commission_rate, + is_max=is_max, + tradable_balance=tradable_balance, + skip_fcm_risk_check=skip_fcm_risk_check, + leverage=leverage, + margin_type=margin_type, + **kwargs, + ) + + +def preview_limit_order_gtd_buy( + self, + product_id: str, + base_size: str, + limit_price: str, + end_time: str, + post_only: bool = False, + commission_rate: Optional[str] = None, + is_max: Optional[bool] = False, + tradable_balance: Optional[str] = None, + skip_fcm_risk_check: Optional[bool] = False, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Preview Limit Order GTD Buy** + _______________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/preview + + __________ + + **Description:** + + Preview the results of a limit order GTD buy request before sending. + + __________ + + **Read more on the official documentation:** `Preview Order + `_ + """ + return preview_limit_order_gtd( + self, + product_id, + "BUY", + base_size=base_size, + limit_price=limit_price, + end_time=end_time, + post_only=post_only, + commission_rate=commission_rate, + is_max=is_max, + tradable_balance=tradable_balance, + skip_fcm_risk_check=skip_fcm_risk_check, + leverage=leverage, + margin_type=margin_type, + **kwargs, + ) + + +def preview_limit_order_gtd_sell( + self, + product_id: str, + base_size: str, + limit_price: str, + end_time: str, + post_only: bool = False, + commission_rate: Optional[str] = None, + is_max: Optional[bool] = False, + tradable_balance: Optional[str] = None, + skip_fcm_risk_check: Optional[bool] = False, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Preview Limit Order GTD Sell** + ________________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/preview + + __________ + + **Description:** + + Preview the results of a limit order GTD sell request before sending. + + __________ + + **Read more on the official documentation:** `Preview Order + `_ + """ + return preview_limit_order_gtd( + self, + product_id, + "SELL", + base_size=base_size, + limit_price=limit_price, + end_time=end_time, + post_only=post_only, + commission_rate=commission_rate, + is_max=is_max, + tradable_balance=tradable_balance, + skip_fcm_risk_check=skip_fcm_risk_check, + leverage=leverage, + margin_type=margin_type, + **kwargs, + ) + + +# Preview Stop-Limit GTC orders +def preview_stop_limit_order_gtc( + self, + product_id: str, + side: str, + base_size: str, + limit_price: str, + stop_price: str, + stop_direction: str, + commission_rate: Optional[str] = None, + is_max: Optional[bool] = False, + tradable_balance: Optional[str] = None, + skip_fcm_risk_check: Optional[bool] = False, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Preview Stop-Limit Order GTC** + ________________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/preview + + __________ + + **Description:** + + Preview the results of a stop limit GTC order request before sending. + + __________ + + **Read more on the official documentation:** `Preview Order + `_ + """ + order_configuration = { + "stop_limit_stop_limit_gtc": { + "base_size": base_size, + "limit_price": limit_price, + "stop_price": stop_price, + "stop_direction": stop_direction, + } + } + + return preview_order( + self, + product_id, + side, + order_configuration, + commission_rate=commission_rate, + is_max=is_max, + tradable_balance=tradable_balance, + skip_fcm_risk_check=skip_fcm_risk_check, + leverage=leverage, + margin_type=margin_type, + **kwargs, + ) + + +def preview_stop_limit_order_gtc_buy( + self, + product_id: str, + base_size: str, + limit_price: str, + stop_price: str, + stop_direction: str, + commission_rate: Optional[str] = None, + is_max: Optional[bool] = False, + tradable_balance: Optional[str] = None, + skip_fcm_risk_check: Optional[bool] = False, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Preview Stop-Limit Order GTC Buy** + ____________________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/preview + + __________ + + **Description:** + + Preview the results of a stop limit GTC order buy request before sending. + + __________ + + **Read more on the official documentation:** `Preview Order + `_ + """ + return preview_stop_limit_order_gtc( + self, + product_id, + "BUY", + base_size=base_size, + limit_price=limit_price, + stop_price=stop_price, + stop_direction=stop_direction, + commission_rate=commission_rate, + is_max=is_max, + tradable_balance=tradable_balance, + skip_fcm_risk_check=skip_fcm_risk_check, + leverage=leverage, + margin_type=margin_type, + **kwargs, + ) + + +def preview_stop_limit_order_gtc_sell( + self, + product_id: str, + base_size: str, + limit_price: str, + stop_price: str, + stop_direction: str, + commission_rate: Optional[str] = None, + is_max: Optional[bool] = False, + tradable_balance: Optional[str] = None, + skip_fcm_risk_check: Optional[bool] = False, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Preview Stop-Limit Order GTC Sell** + _____________________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/preview + + __________ + + **Description:** + + Preview the results of a stop limit GTC order sell request before sending. + + __________ + + **Read more on the official documentation:** `Preview Order + `_ + """ + return preview_stop_limit_order_gtc( + self, + product_id, + "SELL", + base_size=base_size, + limit_price=limit_price, + stop_price=stop_price, + stop_direction=stop_direction, + commission_rate=commission_rate, + is_max=is_max, + tradable_balance=tradable_balance, + skip_fcm_risk_check=skip_fcm_risk_check, + leverage=leverage, + margin_type=margin_type, + **kwargs, + ) + + +# Preview Stop-Limit GTD orders +def preview_stop_limit_order_gtd( + self, + product_id: str, + side: str, + base_size: str, + limit_price: str, + stop_price: str, + end_time: str, + stop_direction: str, + commission_rate: Optional[str] = None, + is_max: Optional[bool] = False, + tradable_balance: Optional[str] = None, + skip_fcm_risk_check: Optional[bool] = False, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Preview Stop-Limit Order GTD** + ________________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/preview + + __________ + + **Description:** + + Preview the results of a stop limit GTD order request before sending. + + __________ + + **Read more on the official documentation:** `Preview Order + `_ + """ + order_configuration = { + "stop_limit_stop_limit_gtd": { + "base_size": base_size, + "limit_price": limit_price, + "stop_price": stop_price, + "end_time": end_time, + "stop_direction": stop_direction, + } + } + + return preview_order( + self, + product_id, + side, + order_configuration, + commission_rate=commission_rate, + is_max=is_max, + tradable_balance=tradable_balance, + skip_fcm_risk_check=skip_fcm_risk_check, + leverage=leverage, + margin_type=margin_type, + **kwargs, + ) + + +def preview_stop_limit_order_gtd_buy( + self, + product_id: str, + base_size: str, + limit_price: str, + stop_price: str, + end_time: str, + stop_direction: str, + commission_rate: Optional[str] = None, + is_max: Optional[bool] = False, + tradable_balance: Optional[str] = None, + skip_fcm_risk_check: Optional[bool] = False, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Preview Stop-Limit Order GTD Buy** + ____________________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/preview + + __________ + + **Description:** + + Preview the results of a stop limit GTD order buy request before sending. + + __________ + + **Read more on the official documentation:** `Preview Order + `_ + """ + return preview_stop_limit_order_gtd( + self, + product_id, + "BUY", + base_size=base_size, + limit_price=limit_price, + stop_price=stop_price, + end_time=end_time, + stop_direction=stop_direction, + commission_rate=commission_rate, + is_max=is_max, + tradable_balance=tradable_balance, + skip_fcm_risk_check=skip_fcm_risk_check, + leverage=leverage, + margin_type=margin_type, + **kwargs, + ) + + +def preview_stop_limit_order_gtd_sell( + self, + product_id: str, + base_size: str, + limit_price: str, + stop_price: str, + end_time: str, + stop_direction: str, + commission_rate: Optional[str] = None, + is_max: Optional[bool] = False, + tradable_balance: Optional[str] = None, + skip_fcm_risk_check: Optional[bool] = False, + leverage: Optional[str] = None, + margin_type: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **Preview Stop-Limit Order GTD Sell** + _____________________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/preview + + __________ + + **Description:** + + Preview the results of a stop limit GTD order sell request before sending. + + __________ + + **Read more on the official documentation:** `Preview Order + `_ + """ + return preview_stop_limit_order_gtd( + self, + product_id, + "SELL", + base_size=base_size, + limit_price=limit_price, + stop_price=stop_price, + end_time=end_time, + stop_direction=stop_direction, + commission_rate=commission_rate, + is_max=is_max, + tradable_balance=tradable_balance, + skip_fcm_risk_check=skip_fcm_risk_check, + leverage=leverage, + margin_type=margin_type, + **kwargs, + ) diff --git a/coinbase/rest/perpetuals.py b/coinbase/rest/perpetuals.py new file mode 100644 index 0000000..e328fea --- /dev/null +++ b/coinbase/rest/perpetuals.py @@ -0,0 +1,107 @@ +from typing import Any, Dict, Optional + +from coinbase.constants import API_PREFIX + + +def allocate_portfolio( + self, portfolio_uuid: str, symbol: str, amount: str, currency: str, **kwargs +) -> Dict[str, Any]: + """ + **Allocate Portfolio** + ________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/intx/allocate + + __________ + + **Description:** + + Allocate more funds to an isolated position in your Perpetuals portfolio. + + __________ + + **Read more on the official documentation:** `Allocate Portfolio + `_ + """ + + endpoint = f"{API_PREFIX}/intx/allocate" + + data = { + "portfolio_uuid": portfolio_uuid, + "symbol": symbol, + "amount": amount, + "currency": currency, + } + + return self.post(endpoint, data=data, **kwargs) + + +def get_perps_portfolio_summary(self, portfolio_uuid: str, **kwargs) -> Dict[str, Any]: + """ + **Get Perpetuals Portfolio Summary** + ________________ + + [GET] https://api.coinbase.com/api/v3/brokerage/intx/portfolio/{portfolio_uuid} + + __________ + + **Description:** + + Get a summary of your Perpetuals portfolio. + + __________ + + **Read more on the official documentation:** `Get Perpetuals Portfolio Summary + `_ + """ + endpoint = f"{API_PREFIX}/intx/portfolio/{portfolio_uuid}" + + return self.get(endpoint, **kwargs) + + +def list_perps_positions(self, portfolio_uuid: str, **kwargs) -> Dict[str, Any]: + """ + **List Perpetuals Positions** + ________________ + + [GET] https://api.coinbase.com/api/v3/brokerage/intx/positions/{portfolio_uuid} + + __________ + + **Description:** + + Get a list of open positions in your Perpetuals portfolio. + + __________ + + **Read more on the official documentation:** `List Perpetuals Positions + `_ + """ + endpoint = f"{API_PREFIX}/intx/positions/{portfolio_uuid}" + + return self.get(endpoint, **kwargs) + + +def get_perps_position( + self, portfolio_uuid: str, symbol: str, **kwargs +) -> Dict[str, Any]: + """ + **Get Perpetuals Position** + ________________ + + [GET] https://api.coinbase.com/api/v3/brokerage/intx/positions/{portfolio_uuid}/{symbol} + + __________ + + **Description:** + + Get a specific open position in your Perpetuals portfolio + + __________ + + **Read more on the official documentation:** `Get Perpetuals Positions + `_ + """ + endpoint = f"{API_PREFIX}/intx/positions/{portfolio_uuid}/{symbol}" + + return self.get(endpoint, **kwargs) diff --git a/coinbase/rest/portfolios.py b/coinbase/rest/portfolios.py new file mode 100644 index 0000000..be13b5d --- /dev/null +++ b/coinbase/rest/portfolios.py @@ -0,0 +1,169 @@ +from typing import Any, Dict, Optional + +from coinbase.constants import API_PREFIX + + +def get_portfolios( + self, portfolio_type: Optional[str] = None, **kwargs +) -> Dict[str, Any]: + """ + **List Portfolios** + ___________________ + + [GET] https://api.coinbase.com/api/v3/brokerage/portfolios + + __________ + + **Description:** + + Get a list of all portfolios of a user. + + __________ + + **Read more on the official documentation:** `List Portfolios + `_ + """ + endpoint = f"{API_PREFIX}/portfolios" + + params = {"portfolio_type": portfolio_type} + + return self.get(endpoint, params=params, **kwargs) + + +def create_portfolio(self, name: str, **kwargs) -> Dict[str, Any]: + """ + **Create Portfolio** + ____________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/portfolios + + __________ + + **Description:** + + Create a portfolio. + + __________ + + **Read more on the official documentation:** `Create Portfolio + `_ + """ + endpoint = f"{API_PREFIX}/portfolios" + + data = { + "name": name, + } + + return self.post(endpoint, data=data, **kwargs) + + +def get_portfolio_breakdown(self, portfolio_uuid: str, **kwargs) -> Dict[str, Any]: + """ + **Get Portfolio Breakdown** + ___________________________ + + [GET] https://api.coinbase.com/api/v3/brokerage/portfolios/{portfolio_uuid} + + __________ + + **Description:** + + Get the breakdown of a portfolio by portfolio ID. + + __________ + + **Read more on the official documentation:** `Get Portfolio Breakdown + `_ + """ + endpoint = f"{API_PREFIX}/portfolios/{portfolio_uuid}" + + return self.get(endpoint, **kwargs) + + +def move_portfolio_funds( + self, + value: str, + currency: str, + source_portfolio_uuid: str, + target_portfolio_uuid: str, + **kwargs, +) -> Dict[str, Any]: + """ + **Move Portfolio Funds** + ________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/portfolios/move_funds + + __________ + + **Description:** + + Transfer funds between portfolios. + + __________ + + **Read more on the official documentation:** `Move Portfolio Funds + `_ + """ + endpoint = f"{API_PREFIX}/portfolios/move_funds" + + data = { + "funds": { + "value": value, + "currency": currency, + }, + "source_portfolio_uuid": source_portfolio_uuid, + "target_portfolio_uuid": target_portfolio_uuid, + } + + return self.post(endpoint, data=data, **kwargs) + + +def edit_portfolio(self, portfolio_uuid: str, name: str, **kwargs) -> Dict[str, Any]: + """ + **Edit Portfolio** + __________________ + + [PUT] https://api.coinbase.com/api/v3/brokerage/portfolios/{portfolio_uuid} + + __________ + + **Description:** + + Modify a portfolio by portfolio ID. + + __________ + + **Read more on the official documentation:** `Edit Portfolio + `_ + """ + endpoint = f"{API_PREFIX}/portfolios/{portfolio_uuid}" + + data = { + "name": name, + } + + return self.put(endpoint, data=data, **kwargs) + + +def delete_portfolio(self, portfolio_uuid: str, **kwargs) -> Dict[str, Any]: + """ + **Delete Portfolio** + ____________________ + + [DELETE] https://api.coinbase.com/api/v3/brokerage/portfolios/{portfolio_uuid} + + __________ + + **Description:** + + Delete a portfolio by portfolio ID. + + __________ + + **Read more on the official documentation:** `Delete Portfolio + `_ + """ + endpoint = f"{API_PREFIX}/portfolios/{portfolio_uuid}" + + return self.delete(endpoint, **kwargs) diff --git a/coinbase/rest/products.py b/coinbase/rest/products.py new file mode 100644 index 0000000..c633ed5 --- /dev/null +++ b/coinbase/rest/products.py @@ -0,0 +1,123 @@ +from typing import Any, Dict, List, Optional + +from coinbase.constants import API_PREFIX + + +def get_products( + self, + limit: Optional[int] = None, + offset: Optional[int] = None, + product_type: Optional[str] = None, + product_ids: Optional[List[str]] = None, + contract_expiry_type: Optional[str] = None, + expiring_contract_status: Optional[str] = None, + **kwargs, +) -> Dict[str, Any]: + """ + **List Products** + _________________ + + [GET] https://api.coinbase.com/api/v3/brokerage/products + + __________ + + **Description:** + + Get a list of the available currency pairs for trading. + + __________ + + **Read more on the official documentation:** `List Products + `_ + """ + endpoint = f"{API_PREFIX}/products" + + params = { + "limit": limit, + "offset": offset, + "product_type": product_type, + "product_ids": product_ids, + "contract_expiry_type": contract_expiry_type, + "expiring_contract_status": expiring_contract_status, + } + + return self.get(endpoint, params=params, **kwargs) + + +def get_product(self, product_id: str, **kwargs) -> Dict[str, Any]: + """ + **Get Product** + _______________ + + [GET] https://api.coinbase.com/api/v3/brokerage/products/{product_id} + + __________ + + **Description:** + + Get information on a single product by product ID. + + __________ + + **Read more on the official documentation:** `Get Product + `_ + """ + endpoint = f"{API_PREFIX}/products/{product_id}" + + return self.get(endpoint, **kwargs) + + +def get_product_book( + self, product_id: str, limit: Optional[int] = None, **kwargs +) -> Dict[str, Any]: + """ + **Get Product Book** + ____________________ + + [GET] https://api.coinbase.com/api/v3/brokerage/product_book + + __________ + + **Description:** + + Get a list of bids/asks for a single product. The amount of detail shown can be customized with the limit parameter. + + __________ + + **Read more on the official documentation:** `Get Product Book + `_ + """ + endpoint = f"{API_PREFIX}/product_book" + + params = {"product_id": product_id, "limit": limit} + + return self.get(endpoint, params=params, **kwargs) + + +def get_best_bid_ask( + self, product_ids: Optional[List[str]] = None, **kwargs +) -> Dict[str, Any]: + """ + **Get Best Bid/Ask** + ____________________ + + [GET] https://api.coinbase.com/api/v3/brokerage/best_bid_ask + + __________ + + **Description:** + + Get the best bid/ask for all products. A subset of all products can be returned instead by using the product_ids input. + + __________ + + **Read more on the official documentation:** `Get Best Bid/Ask + `_ + """ + endpoint = f"{API_PREFIX}/best_bid_ask" + + params = { + "product_ids": product_ids, + } + + return self.get(endpoint, params=params, **kwargs) diff --git a/coinbase/rest/rest_base.py b/coinbase/rest/rest_base.py new file mode 100644 index 0000000..41cf8fb --- /dev/null +++ b/coinbase/rest/rest_base.py @@ -0,0 +1,239 @@ +import logging +import os +from typing import IO, Any, Dict, Optional, Union + +import requests +from requests.exceptions import HTTPError + +from coinbase import jwt_generator +from coinbase.api_base import APIBase, get_logger +from coinbase.constants import API_ENV_KEY, API_SECRET_ENV_KEY, BASE_URL, USER_AGENT + +logger = get_logger("coinbase.RESTClient") + + +def handle_exception(response): + """Raises :class:`HTTPError`, if one occurred. + + :meta private: + """ + http_error_msg = "" + reason = response.reason + + if 400 <= response.status_code < 500: + if ( + response.status_code == 403 + and '"error_details":"Missing required scopes"' in response.text + ): + http_error_msg = f"{response.status_code} Client Error: Missing Required Scopes. Please verify your API keys include the necessary permissions." + else: + http_error_msg = ( + f"{response.status_code} Client Error: {reason} {response.text}" + ) + elif 500 <= response.status_code < 600: + http_error_msg = ( + f"{response.status_code} Server Error: {reason} {response.text}" + ) + + if http_error_msg: + logger.error(f"HTTP Error: {http_error_msg}") + raise HTTPError(http_error_msg, response=response) + + +class RESTBase(APIBase): + """ + **RESTClient** + _____________________________ + + Initialize using RESTClient + + __________ + + **Parameters**: + + - **api_key | Optional (str)** - The API key + - **api_secret | Optional (str)** - The API key secret + - **key_file | Optional (IO | str)** - Path to API key file or file-like object + - **base_url | (str)** - The base URL for REST requests. Default set to "https://api.coinbase.com" + - **timeout | Optional (int)** - Set timeout in seconds for REST requests + - **verbose | Optional (bool)** - Enables debug logging. Default set to False + + + """ + + def __init__( + self, + api_key: Optional[str] = os.getenv(API_ENV_KEY), + api_secret: Optional[str] = os.getenv(API_SECRET_ENV_KEY), + key_file: Optional[Union[IO, str]] = None, + base_url=BASE_URL, + timeout: Optional[int] = None, + verbose: Optional[bool] = False, + ): + super().__init__( + api_key=api_key, + api_secret=api_secret, + key_file=key_file, + base_url=base_url, + timeout=timeout, + verbose=verbose, + ) + if verbose: + logger.setLevel(logging.DEBUG) + + def get(self, url_path, params: Optional[dict] = None, **kwargs) -> Dict[str, Any]: + """ + **Authenticated GET Request** + _____________________________ + + __________ + + **Parameters:** + + - **url_path | (str)** - the URL path + - **params | Optional ([dict])** - the query parameters + + + """ + + params = params or {} + + if kwargs: + params.update(kwargs) + + return self.prepare_and_send_request("GET", url_path, params, data=None) + + def post( + self, + url_path, + params: Optional[dict] = None, + data: Optional[dict] = None, + **kwargs, + ) -> Dict[str, Any]: + """ + **Authenticated POST Request** + ______________________________ + + __________ + + **Parameters:** + + - **url_path | (str)** - the URL path + - **params | Optional ([dict])** - the query parameters + - **data | Optional ([dict])** - the request body + """ + data = data or {} + + if kwargs: + data.update(kwargs) + + return self.prepare_and_send_request("POST", url_path, params, data) + + def put( + self, + url_path, + params: Optional[dict] = None, + data: Optional[dict] = None, + **kwargs, + ) -> Dict[str, Any]: + """ + **Authenticated PUT Request** + _____________________________ + + __________ + + **Parameters:** + + - **url_path | (str)** - the URL path + - **params | Optional ([dict])** - the query parameters + - **data | Optional ([dict])** - the request body + """ + data = data or {} + + if kwargs: + data.update(kwargs) + + return self.prepare_and_send_request("PUT", url_path, params, data) + + def delete( + self, + url_path, + params: Optional[dict] = None, + data: Optional[dict] = None, + **kwargs, + ) -> Dict[str, Any]: + """ + **Authenticated DELETE Request** + ________________________________ + + __________ + + **Parameters:** + + - **url_path | (str)** - the URL path + - **params | Optional ([dict])** - the query parameters + - **data | Optional ([dict])** - the request body + """ + data = data or {} + + if kwargs: + data.update(kwargs) + + return self.prepare_and_send_request("DELETE", url_path, params, data) + + def prepare_and_send_request( + self, + http_method, + url_path, + params: Optional[dict] = None, + data: Optional[dict] = None, + ): + """ + :meta private: + """ + headers = self.set_headers(http_method, url_path) + + if params is not None: + params = {key: value for key, value in params.items() if value is not None} + + if data is not None: + data = {key: value for key, value in data.items() if value is not None} + + return self.send_request(http_method, url_path, params, headers, data=data) + + def send_request(self, http_method, url_path, params, headers, data=None): + """ + :meta private: + """ + if data is None: + data = {} + + url = f"https://{self.base_url}{url_path}" + + logger.debug(f"Sending {http_method} request to {url}") + + response = requests.request( + http_method, + url, + params=params, + json=data, + headers=headers, + timeout=self.timeout, + ) + handle_exception(response) # Raise an HTTPError for bad responses + + logger.debug(f"Raw response: {response.json()}") + + return response.json() + + def set_headers(self, method, path): + """ + :meta private: + """ + uri = f"{method} {self.base_url}{path}" + jwt = jwt_generator.build_rest_jwt(uri, self.api_key, self.api_secret) + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {jwt}", + "User-Agent": USER_AGENT, + } diff --git a/coinbase/websocket/__init__.py b/coinbase/websocket/__init__.py new file mode 100644 index 0000000..0bbeebf --- /dev/null +++ b/coinbase/websocket/__init__.py @@ -0,0 +1,38 @@ +from .websocket_base import WSBase, WSClientConnectionClosedException, WSClientException + + +class WSClient(WSBase): + from .channels import ( + candles, + candles_async, + candles_unsubscribe, + candles_unsubscribe_async, + heartbeats, + heartbeats_async, + heartbeats_unsubscribe, + heartbeats_unsubscribe_async, + level2, + level2_async, + level2_unsubscribe, + level2_unsubscribe_async, + market_trades, + market_trades_async, + market_trades_unsubscribe, + market_trades_unsubscribe_async, + status, + status_async, + status_unsubscribe, + status_unsubscribe_async, + ticker, + ticker_async, + ticker_batch, + ticker_batch_async, + ticker_batch_unsubscribe, + ticker_batch_unsubscribe_async, + ticker_unsubscribe, + ticker_unsubscribe_async, + user, + user_async, + user_unsubscribe, + user_unsubscribe_async, + ) diff --git a/coinbase/websocket/channels.py b/coinbase/websocket/channels.py new file mode 100644 index 0000000..dfebccf --- /dev/null +++ b/coinbase/websocket/channels.py @@ -0,0 +1,620 @@ +from typing import List + +from coinbase.constants import ( + CANDLES, + HEARTBEATS, + LEVEL2, + MARKET_TRADES, + STATUS, + TICKER, + TICKER_BATCH, + USER, +) + + +def heartbeats(self, product_ids: List[str]) -> None: + """ + **Heartbeats Subscribe** + ________________________ + + __________ + + **Description:** + + Subscribe to heartbeats channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Heartbeats Channel + `_ + """ + self.subscribe(product_ids, [HEARTBEATS]) + + +async def heartbeats_async(self, product_ids: List[str]) -> None: + """ + **Heartbeats Subscribe Async** + ______________________________ + + __________ + + **Description:** + + Async subscribe to heartbeats channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Heartbeats Channel + `_ + """ + await self.subscribe_async(product_ids, [HEARTBEATS]) + + +def heartbeats_unsubscribe(self, product_ids: List[str]) -> None: + """ + **Heartbeats Unsubscribe** + __________________________ + + __________ + + **Description:** + + Unsubscribe to heartbeats channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Heartbeats Channel + `_ + """ + self.unsubscribe(product_ids, [HEARTBEATS]) + + +async def heartbeats_unsubscribe_async(self, product_ids: List[str]) -> None: + """ + **Heartbeats Unsubscribe Async** + ________________________________ + + __________ + + **Description:** + + Async unsubscribe to heartbeats channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Heartbeats Channel + `_ + """ + await self.unsubscribe_async(product_ids, [HEARTBEATS]) + + +def candles(self, product_ids: List[str]) -> None: + """ + **Candles Subscribe** + _____________________ + + __________ + + **Description:** + + Subscribe to candles channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Candles Channel + `_ + """ + self.subscribe(product_ids, [CANDLES]) + + +async def candles_async(self, product_ids: List[str]) -> None: + """ + **Candles Subscribe Async** + ___________________________ + + __________ + + **Description:** + + Async subscribe to candles channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Candles Channel + `_ + """ + await self.subscribe_async(product_ids, [CANDLES]) + + +def candles_unsubscribe(self, product_ids: List[str]) -> None: + """ + **Candles Unsubscribe** + _______________________ + + __________ + + **Description:** + + Unsubscribe to candles channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Candles Channel + `_ + """ + self.unsubscribe(product_ids, [CANDLES]) + + +async def candles_unsubscribe_async(self, product_ids: List[str]) -> None: + """ + **Candles Unsubscribe Async** + _____________________________ + + __________ + + **Description:** + + Async unsubscribe to candles channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Candles Channel + `_ + """ + await self.unsubscribe_async(product_ids, [CANDLES]) + + +def market_trades(self, product_ids: List[str]) -> None: + """ + **Market Trades Subscribe** + ___________________________ + + __________ + + **Description:** + + Subscribe to market_trades channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Market Trades Channel + `_ + """ + self.subscribe(product_ids, [MARKET_TRADES]) + + +async def market_trades_async(self, product_ids: List[str]) -> None: + """ + **Market Trades Subscribe Async** + _________________________________ + + __________ + + **Description:** + + Async subscribe to market_trades channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Market Trades Channel + `_ + """ + await self.subscribe_async(product_ids, [MARKET_TRADES]) + + +def market_trades_unsubscribe(self, product_ids: List[str]) -> None: + """ + **Market Trades Unsubscribe** + _____________________________ + + __________ + + **Description:** + + Unsubscribe to market_trades channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Market Trades Channel + `_ + """ + self.unsubscribe(product_ids, [MARKET_TRADES]) + + +async def market_trades_unsubscribe_async(self, product_ids: List[str]) -> None: + """ + **Market Trades Unsubscribe Async** + ___________________________________ + + __________ + + **Description:** + + Async unsubscribe to market_trades channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Market Trades Channel + `_ + """ + await self.unsubscribe_async(product_ids, [MARKET_TRADES]) + + +def status(self, product_ids: List[str]) -> None: + """ + **Status Subscribe** + ____________________ + + __________ + + **Description:** + + Subscribe to status channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Status Channel + `_ + """ + self.subscribe(product_ids, [STATUS]) + + +async def status_async(self, product_ids: List[str]) -> None: + """ + **Status Subscribe Async** + __________________________ + + __________ + + **Description:** + + Async subscribe to status channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Status Channel + `_ + """ + await self.subscribe_async(product_ids, [STATUS]) + + +def status_unsubscribe(self, product_ids: List[str]) -> None: + """ + **Status Unsubscribe** + ______________________ + + __________ + + **Description:** + + Unsubscribe to status channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Status Channel + `_ + """ + self.unsubscribe(product_ids, [STATUS]) + + +async def status_unsubscribe_async(self, product_ids: List[str]) -> None: + """ + **Status Unsubscribe Async** + ____________________________ + + __________ + + **Description:** + + Async unsubscribe to status channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Status Channel + `_ + """ + await self.unsubscribe_async(product_ids, [STATUS]) + + +def ticker(self, product_ids: List[str]) -> None: + """ + **Ticker Subscribe** + ____________________ + + __________ + + **Description:** + + Subscribe to ticker channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Ticker Channel + `_ + """ + self.subscribe(product_ids, [TICKER]) + + +async def ticker_async(self, product_ids: List[str]) -> None: + """ + **Ticker Subscribe Async** + __________________________ + + __________ + + **Description:** + + Async subscribe to ticker channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Ticker Channel + `_ + """ + await self.subscribe_async(product_ids, [TICKER]) + + +def ticker_unsubscribe(self, product_ids: List[str]) -> None: + """ + **Ticker Unsubscribe** + ______________________ + + __________ + + **Description:** + + Unsubscribe to ticker channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Ticker Channel + `_ + """ + self.unsubscribe(product_ids, [TICKER]) + + +async def ticker_unsubscribe_async(self, product_ids: List[str]) -> None: + """ + **Ticker Unsubscribe Async** + ____________________________ + + __________ + + **Description:** + + Async unsubscribe to ticker channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Ticker Channel + `_ + """ + await self.unsubscribe_async(product_ids, [TICKER]) + + +def ticker_batch(self, product_ids: List[str]) -> None: + """ + **Ticker Batch Subscribe** + __________________________ + + __________ + + **Description:** + + Subscribe to ticker_batch channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Ticker Batch Channel + `_ + """ + self.subscribe(product_ids, [TICKER_BATCH]) + + +async def ticker_batch_async(self, product_ids: List[str]) -> None: + """ + **Ticker Batch Subscribe Async** + ________________________________ + + __________ + + **Description:** + + Async subscribe to ticker_batch channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Ticker Batch Channel + `_ + """ + await self.subscribe_async(product_ids, [TICKER_BATCH]) + + +def ticker_batch_unsubscribe(self, product_ids: List[str]) -> None: + """ + **Ticker Batch Unsubscribe** + ____________________________ + + __________ + + **Description:** + + Unsubscribe to ticker_batch channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Ticker Batch Channel + `_ + """ + self.unsubscribe(product_ids, [TICKER_BATCH]) + + +async def ticker_batch_unsubscribe_async(self, product_ids: List[str]) -> None: + """ + **Ticker Batch Unsubscribe Async** + __________________________________ + + __________ + + **Description:** + + Async unsubscribe to ticker_batch channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Ticker Batch Channel + `_ + """ + await self.unsubscribe_async(product_ids, [TICKER_BATCH]) + + +def level2(self, product_ids: List[str]) -> None: + """ + **Level2 Subscribe** + ____________________ + + __________ + + **Description:** + + Subscribe to level2 channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Level2 Channel + `_ + """ + self.subscribe(product_ids, [LEVEL2]) + + +async def level2_async(self, product_ids: List[str]) -> None: + """ + **Level2 Subscribe Async** + __________________________ + + __________ + + **Description:** + + Async subscribe to level2 channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Level2 Channel + `_ + """ + await self.subscribe_async(product_ids, [LEVEL2]) + + +def level2_unsubscribe(self, product_ids: List[str]) -> None: + """ + **Level2 Unsubscribe** + ______________________ + + __________ + + **Description:** + + Unsubscribe to level2 channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Level2 Channel + `_ + """ + self.unsubscribe(product_ids, [LEVEL2]) + + +async def level2_unsubscribe_async(self, product_ids: List[str]) -> None: + """ + **Level2 Unsubscribe Async** + ____________________________ + + __________ + + **Description:** + + Async unsubscribe to level2 channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `Level2 Channel + `_ + """ + await self.unsubscribe_async(product_ids, [LEVEL2]) + + +def user(self, product_ids: List[str]) -> None: + """ + **User Subscribe** + __________________ + + __________ + + **Description:** + + Subscribe to user channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `User Channel + `_ + """ + self.subscribe(product_ids, [USER]) + + +async def user_async(self, product_ids: List[str]) -> None: + """ + **User Subscribe Async** + ________________________ + + __________ + + **Description:** + + Async subscribe to user channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `User Channel + `_ + """ + await self.subscribe_async(product_ids, [USER]) + + +def user_unsubscribe(self, product_ids: List[str]) -> None: + """ + **User Unsubscribe** + ____________________ + + __________ + + **Description:** + + Unsubscribe to user channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `User Channel + `_ + """ + self.unsubscribe(product_ids, [USER]) + + +async def user_unsubscribe_async(self, product_ids: List[str]) -> None: + """ + **User Unsubscribe Async** + __________________________ + + __________ + + **Description:** + + Async unsubscribe to user channel for a list of products_ids. + + __________ + + **Read more on the official documentation:** `User Channel + `_ + """ + await self.unsubscribe_async(product_ids, [USER]) diff --git a/coinbase/websocket/websocket_base.py b/coinbase/websocket/websocket_base.py new file mode 100644 index 0000000..8326551 --- /dev/null +++ b/coinbase/websocket/websocket_base.py @@ -0,0 +1,586 @@ +import asyncio +import json +import logging +import os +import threading +import time +from typing import IO, Callable, List, Optional, Union + +import backoff +import websockets + +from coinbase import jwt_generator +from coinbase.api_base import APIBase, get_logger +from coinbase.constants import ( + API_ENV_KEY, + API_SECRET_ENV_KEY, + SUBSCRIBE_MESSAGE_TYPE, + UNSUBSCRIBE_MESSAGE_TYPE, + USER_AGENT, + WS_BASE_URL, + WS_RETRY_BASE, + WS_RETRY_FACTOR, + WS_RETRY_MAX, +) + +logger = get_logger("coinbase.WSClient") + + +class WSClientException(Exception): + """ + **WSClientException** + ________________________________________ + + ----------------------------------------- + + Exception raised for errors in the WebSocket client. + """ + + pass + + +class WSClientConnectionClosedException(Exception): + """ + **WSClientConnectionClosedException** + ________________________________________ + + ---------------------------------------- + + Exception raised for unexpected closure in the WebSocket client. + """ + + pass + + +class WSBase(APIBase): + """ + **WSBase Client** + _____________________________ + + Initialize using WSClient + + __________ + + **Parameters**: + + - **api_key | Optional (str)** - The API key + - **api_secret | Optional (str)** - The API key secret + - **key_file | Optional (IO | str)** - Path to API key file or file-like object + - **base_url | (str)** - The websocket base url. Default set to "wss://advanced-trade-ws.coinbase.com" + - **timeout | Optional (int)** - Set timeout in seconds for REST requests + - **max_size | Optional (int)** - Max size in bytes for messages received. Default set to (10 * 1024 * 1024) + - **on_message | Optional (Callable[[str], None])** - Function called when a message is received + - **on_open | Optional ([Callable[[], None]])** - Function called when a connection is opened + - **on_close | Optional ([Callable[[], None]])** - Function called when a connection is closed + - **retry | Optional (bool)** - Enables automatic reconnections. Default set to True + - **verbose | Optional (bool)** - Enables debug logging. Default set to False + + + """ + + def __init__( + self, + api_key: Optional[str] = os.getenv(API_ENV_KEY), + api_secret: Optional[str] = os.getenv(API_SECRET_ENV_KEY), + key_file: Optional[Union[IO, str]] = None, + base_url=WS_BASE_URL, + timeout: Optional[int] = None, + max_size: Optional[int] = 10 * 1024 * 1024, + on_message: Optional[Callable[[str], None]] = None, + on_open: Optional[Callable[[], None]] = None, + on_close: Optional[Callable[[], None]] = None, + retry: Optional[bool] = True, + verbose: Optional[bool] = False, + ): + super().__init__( + api_key=api_key, + api_secret=api_secret, + key_file=key_file, + base_url=base_url, + timeout=timeout, + verbose=verbose, + ) + + if not on_message: + raise WSClientException("on_message callback is required.") + + if verbose: + logger.setLevel(logging.DEBUG) + + self.max_size = max_size + self.on_message = on_message + self.on_open = on_open + self.on_close = on_close + + self.websocket = None + self.loop = None + self.thread = None + + self.retry = retry + self._retry_max_tries = WS_RETRY_MAX + self._retry_base = WS_RETRY_BASE + self._retry_factor = WS_RETRY_FACTOR + self._retry_count = 0 + + self.subscriptions = {} + self._background_exception = None + self._retrying = False + + def open(self) -> None: + """ + **Open Websocket** + __________________ + + ------------------------ + + Open the websocket client connection. + """ + if not self.loop or self.loop.is_closed(): + self.loop = asyncio.new_event_loop() # Create a new event loop + self.thread = threading.Thread(target=self.loop.run_forever) + self.thread.daemon = True + self.thread.start() + + self._run_coroutine_threadsafe(self.open_async()) + + async def open_async(self) -> None: + """ + **Open Websocket Async** + ________________________ + + ------------------------ + + Open the websocket client connection asynchronously. + """ + self._ensure_websocket_not_open() + + headers = self._set_headers() + + logger.debug("Connecting to %s", self.base_url) + try: + self.websocket = await websockets.connect( + self.base_url, + open_timeout=self.timeout, + max_size=self.max_size, + user_agent_header=USER_AGENT, + extra_headers=headers, + ) + logger.debug("Successfully connected to %s", self.base_url) + + if self.on_open: + self.on_open() + + # Start the message handler coroutine after establishing connection + if not self._retrying: + asyncio.create_task(self._message_handler()) + except asyncio.TimeoutError as toe: + self.websocket = None + logger.error("Connection attempt timed out: %s", toe) + raise WSClientException("Connection attempt timed out") from toe + except (websockets.exceptions.WebSocketException, OSError) as wse: + self.websocket = None + logger.error("Failed to establish WebSocket connection: %s", wse) + raise WSClientException("Failed to establish WebSocket connection") from wse + + def close(self) -> None: + """ + **Close Websocket** + ___________________ + + ------------------------ + + Close the websocket client connection. + """ + if self.loop and not self.loop.is_closed(): + # Schedule the asynchronous close + self._run_coroutine_threadsafe(self.close_async()) + # Stop the event loop + self.loop.call_soon_threadsafe(self.loop.stop) + # Wait for the thread to finish + self.thread.join() + # Close the event loop + self.loop.close() + else: + raise WSClientException("Event loop is not running.") + + async def close_async(self) -> None: + """ + **Close Websocket Async** + _________________________ + + ------------------------ + + Close the websocket client connection asynchronously. + """ + self._ensure_websocket_open() + + logger.debug("Closing connection to %s", self.base_url) + try: + await self.websocket.close() + self.websocket = None + self.subscriptions = {} + + logger.debug("Connection closed to %s", self.base_url) + + if self.on_close: + self.on_close() + except (websockets.exceptions.WebSocketException, OSError) as wse: + logger.error("Failed to close WebSocket connection: %s", wse) + raise WSClientException("Failed to close WebSocket connection.") from wse + + def subscribe(self, product_ids: List[str], channels: List[str]) -> None: + """ + **Subscribe** + _____________ + + ------------------------ + + Subscribe to a list of channels for a list of product ids. + + - **product_ids** - product ids to subscribe to + - **channels** - channels to subscribe to + """ + if self.loop and not self.loop.is_closed(): + self._run_coroutine_threadsafe(self.subscribe_async(product_ids, channels)) + else: + raise WSClientException("Websocket Client is not open.") + + async def subscribe_async( + self, product_ids: List[str], channels: List[str] + ) -> None: + """ + **Subscribe Async** + ___________________ + + ------------------------ + + Async subscribe to a list of channels for a list of product ids. + + - **product_ids** - product ids to subscribe to + - **channels** - channels to subscribe to + """ + self._ensure_websocket_open() + for channel in channels: + try: + message = self._build_subscription_message( + product_ids, channel, SUBSCRIBE_MESSAGE_TYPE + ) + json_message = json.dumps(message) + + logger.debug( + "Subscribing to channel %s for product IDs: %s", + channel, + product_ids, + ) + + await self.websocket.send(json_message) + + logger.debug("Successfully subscribed") + + # add to subscriptions map + if channel not in self.subscriptions: + self.subscriptions[channel] = set() + self.subscriptions[channel].update(product_ids) + except websockets.exceptions.WebSocketException as wse: + logger.error( + "Failed to subscribe to %s channel for product IDs %s: %s", + channel, + product_ids, + wse, + ) + raise WSClientException( + f"Failed to subscribe to {channel} channel for product ids {product_ids}." + ) from wse + + def unsubscribe(self, product_ids: List[str], channels: List[str]) -> None: + """ + **Unsubscribe** + _______________ + + ------------------------ + + Unsubscribe to a list of channels for a list of product ids. + + - **product_ids** - product ids to unsubscribe from + - **channels** - channels to unsubscribe from + """ + if self.loop and not self.loop.is_closed(): + self._run_coroutine_threadsafe( + self.unsubscribe_async(product_ids, channels) + ) + else: + raise WSClientException("Websocket Client is not open.") + + async def unsubscribe_async( + self, product_ids: List[str], channels: List[str] + ) -> None: + """ + **Unsubscribe Async** + _____________________ + + ------------------------ + + Async unsubscribe to a list of channels for a list of product ids. + + - **product_ids** - product ids to unsubscribe from + - **channels** - channels to unsubscribe from + """ + self._ensure_websocket_open() + for channel in channels: + try: + message = self._build_subscription_message( + product_ids, channel, UNSUBSCRIBE_MESSAGE_TYPE + ) + json_message = json.dumps(message) + + logger.debug( + "Unsubscribing from channel %s for product IDs: %s", + channel, + product_ids, + ) + + await self.websocket.send(json_message) + + logger.debug("Successfully unsubscribed") + + # remove from subscriptions map + if channel in self.subscriptions: + self.subscriptions[channel].difference_update(product_ids) + except (websockets.exceptions.WebSocketException, OSError) as wse: + logger.error( + "Failed to unsubscribe to %s channel for product IDs %s: %s", + channel, + product_ids, + wse, + ) + + raise WSClientException( + f"Failed to unsubscribe to {channel} channel for product ids {product_ids}." + ) from wse + + def unsubscribe_all(self) -> None: + """ + **Unsubscribe All** + ________________________ + + ------------------------ + + Unsubscribe from all channels you are currently subscribed to. + """ + if self.loop and not self.loop.is_closed(): + self._run_coroutine_threadsafe(self.unsubscribe_all_async()) + else: + raise WSClientException("Websocket Client is not open.") + + async def unsubscribe_all_async(self) -> None: + """ + **Unsubscribe All Async** + _________________________ + + ------------------------ + + Async unsubscribe from all channels you are currently subscribed to. + """ + for channel, product_ids in self.subscriptions.items(): + await self.unsubscribe_async(list(product_ids), [channel]) + + def sleep_with_exception_check(self, sleep: int) -> None: + """ + **Sleep with Exception Check** + ______________________________ + + ------------------------ + + Sleep for a specified number of seconds and check for background exceptions. + + - **sleep** - number of seconds to sleep. + """ + time.sleep(sleep) + self.raise_background_exception() + + async def sleep_with_exception_check_async(self, sleep: int) -> None: + """ + **Sleep with Exception Check Async** + ____________________________________ + + ------------------------ + + Async sleep for a specified number of seconds and check for background exceptions. + + - **sleep** - number of seconds to sleep. + """ + await asyncio.sleep(sleep) + self.raise_background_exception() + + def run_forever_with_exception_check(self) -> None: + """ + **Run Forever with Exception Check** + ____________________________________ + + ------------------------ + + Runs an endless loop, checking for background exceptions every second. + """ + while True: + time.sleep(1) + self.raise_background_exception() + + async def run_forever_with_exception_check_async(self) -> None: + """ + **Run Forever with Exception Check Async** + __________________________________________ + + ------------------------ + + Async runs an endless loop, checking for background exceptions every second. + """ + while True: + await asyncio.sleep(1) + self.raise_background_exception() + + def raise_background_exception(self) -> None: + """ + **Raise Background Exception** + ______________________________ + + ------------------------ + + Raise any background exceptions that occurred in the message handler. + """ + if self._background_exception: + exception_to_raise = self._background_exception + self._background_exception = None + raise exception_to_raise + + def _run_coroutine_threadsafe(self, coro): + """ + :meta private: + """ + future = asyncio.run_coroutine_threadsafe(coro, self.loop) + return future.result() + + def _is_websocket_open(self): + """ + :meta private: + """ + return self.websocket and self.websocket.open + + async def _resubscribe(self): + """ + :meta private: + """ + for channel, product_ids in self.subscriptions.items(): + await self.subscribe_async(list(product_ids), [channel]) + + async def _retry_connection(self): + """ + :meta private: + """ + self._retry_count = 0 + + @backoff.on_exception( + backoff.expo, + WSClientException, + max_tries=self._retry_max_tries, + base=self._retry_base, + factor=self._retry_factor, + ) + async def _retry_connect_and_resubscribe(): + self._retry_count += 1 + + logger.debug("Retrying connection attempt %s", self._retry_count) + if not self._is_websocket_open(): + await self.open_async() + + logger.debug("Resubscribing to channels") + self._retry_count = 0 + await self._resubscribe() + + return await _retry_connect_and_resubscribe() + + async def _message_handler(self): + """ + :meta private: + """ + self.handler_open = True + while self._is_websocket_open(): + try: + message = await self.websocket.recv() + if self.on_message: + self.on_message(message) + except websockets.exceptions.ConnectionClosedOK as cco: + logger.debug("Connection closed (OK): %s", cco) + break + except websockets.exceptions.ConnectionClosedError as cce: + logger.error("Connection closed (ERROR): %s", cce) + if self.retry: + self._retrying = True + try: + logger.debug("Retrying connection") + await self._retry_connection() + self._retrying = False + except WSClientException: + logger.error( + "Connection closed unexpectedly. Retry attempts failed." + ) + self._background_exception = WSClientConnectionClosedException( + "Connection closed unexpectedly. Retry attempts failed." + ) + self.subscriptions = {} + self._retrying = False + self._retry_count = 0 + break + else: + logger.error("Connection closed unexpectedly with error: %s", cce) + self._background_exception = WSClientConnectionClosedException( + f"Connection closed unexpectedly with error: {cce}" + ) + self.subscriptions = {} + break + except ( + websockets.exceptions.WebSocketException, + json.JSONDecodeError, + WSClientException, + ) as e: + logger.error("Exception in message handler: %s", e) + self._background_exception = WSClientException( + f"Exception in message handler: {e}" + ) + break + + def _build_subscription_message( + self, product_ids: List[str], channel: str, message_type: str + ): + """ + :meta private: + """ + return { + "type": message_type, + "product_ids": product_ids, + "channel": channel, + "jwt": jwt_generator.build_ws_jwt(self.api_key, self.api_secret), + "timestamp": int(time.time()), + } + + def _ensure_websocket_not_open(self): + """ + :meta private: + """ + if self._is_websocket_open(): + raise WSClientException("WebSocket is already open.") + + def _ensure_websocket_open(self): + """ + :meta private: + """ + if not self._is_websocket_open(): + raise WSClientException("WebSocket is closed or was never opened.") + + def _set_headers(self): + """ + :meta private: + """ + if self._retry_count > 0: + return {"x-cb-retry-counter": str(self._retry_count)} + else: + return {} diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/coinbase.rest.rst b/docs/coinbase.rest.rst new file mode 100644 index 0000000..d693393 --- /dev/null +++ b/docs/coinbase.rest.rst @@ -0,0 +1,99 @@ +REST API Client +===================== + + +RESTClient Constructor +------------------------------- + +.. autofunction:: coinbase.rest.rest_base.RESTBase + +REST Utils +------------------------------- + +.. autofunction:: coinbase.rest.rest_base.RESTBase.get + +.. autofunction:: coinbase.rest.rest_base.RESTBase.post + +.. autofunction:: coinbase.rest.rest_base.RESTBase.put + +.. autofunction:: coinbase.rest.rest_base.RESTBase.delete + +Accounts +----------------------------- + +.. automodule:: coinbase.rest.accounts + :members: + :undoc-members: + :show-inheritance: + +Products +----------------------------- + +.. automodule:: coinbase.rest.products + :members: + :undoc-members: + :show-inheritance: + +Market Data +--------------------------------- + +.. automodule:: coinbase.rest.market_data + :members: + :undoc-members: + :show-inheritance: + +Orders +--------------------------- + +.. automodule:: coinbase.rest.orders + :members: + :undoc-members: + :show-inheritance: + +Portfolios +------------------------------- + +.. automodule:: coinbase.rest.portfolios + :members: + :undoc-members: + :show-inheritance: + +Futures +---------------------------- + +.. automodule:: coinbase.rest.futures + :members: + :undoc-members: + :show-inheritance: + +Perpetuals +--------------------------- + +.. automodule:: coinbase.rest.perpetuals + :members: + :undoc-members: + :show-inheritance: + +Fees +------------------------- + +.. automodule:: coinbase.rest.fees + :members: + :undoc-members: + :show-inheritance: + +Convert +---------------------------- + +.. automodule:: coinbase.rest.convert + :members: + :undoc-members: + :show-inheritance: + +Common +--------------------------- + +.. automodule:: coinbase.rest.common + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/coinbase.websocket.rst b/docs/coinbase.websocket.rst new file mode 100644 index 0000000..6cdab91 --- /dev/null +++ b/docs/coinbase.websocket.rst @@ -0,0 +1,55 @@ +Websocket API Client +===================== + +WSClient Constructor +--------------------------- + +.. autofunction:: coinbase.websocket.websocket_base.WSBase + +WebSocket Utils +--------------------------- + +.. autofunction:: coinbase.websocket.websocket_base.WSBase.open + +.. autofunction:: coinbase.websocket.websocket_base.WSBase.open_async + +.. autofunction:: coinbase.websocket.websocket_base.WSBase.close + +.. autofunction:: coinbase.websocket.websocket_base.WSBase.close_async + +.. autofunction:: coinbase.websocket.websocket_base.WSBase.subscribe + +.. autofunction:: coinbase.websocket.websocket_base.WSBase.subscribe_async + +.. autofunction:: coinbase.websocket.websocket_base.WSBase.unsubscribe + +.. autofunction:: coinbase.websocket.websocket_base.WSBase.unsubscribe_async + +.. autofunction:: coinbase.websocket.websocket_base.WSBase.unsubscribe_all + +.. autofunction:: coinbase.websocket.websocket_base.WSBase.unsubscribe_all_async + +.. autofunction:: coinbase.websocket.websocket_base.WSBase.sleep_with_exception_check + +.. autofunction:: coinbase.websocket.websocket_base.WSBase.sleep_with_exception_check_async + +.. autofunction:: coinbase.websocket.websocket_base.WSBase.run_forever_with_exception_check + +.. autofunction:: coinbase.websocket.websocket_base.WSBase.run_forever_with_exception_check_async + +.. autofunction:: coinbase.websocket.websocket_base.WSBase.raise_background_exception + +Channels +----------------------------- + +.. automodule:: coinbase.websocket.channels + :members: + :undoc-members: + :show-inheritance: + +Exceptions +--------------------------- + +.. autofunction:: coinbase.websocket.websocket_base.WSClientException + +.. autofunction:: coinbase.websocket.websocket_base.WSClientConnectionClosedException diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..63a7308 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,30 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html +import os +import sys + +sys.path.insert(0, os.path.abspath("..")) + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = "Coinbase Advanced API Python SDK" +copyright = "2024, Coinbase" +author = "Coinbase" + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode", "sphinx.ext.napoleon"] + +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "sphinx_rtd_theme" +html_static_path = ["_static"] diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..478618b --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,39 @@ +.. Docs documentation master file, created by + sphinx-quickstart on Wed Jan 17 16:53:39 2024. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Coinbase Advanced API Python SDK +================================ + +------------- + +Getting Started +================================ +.. image:: https://badge.fury.io/py/coinbase-advanced-py.svg + :target: https://pypi.org/project/coinbase-advanced-py/ + :alt: PyPI Version + +.. image:: https://img.shields.io/badge/License-Apache%202.0-green.svg + :target: https://opensource.org/licenses/Apache-2.0 + :alt: Apache License 2.0 + +Welcome to the official Coinbase Advanced API Python SDK. This python project was created to allow coders to easily plug +into the `Coinbase Advanced API `_ + + +- Docs: https://docs.cloud.coinbase.com/advanced-trade-api/docs/welcome +- Python SDK: https://github.com/coinbase/coinbase-advanced-py + +For detailed exercises on how to get started using the SDK look at our SDK Overview: +https://docs.cloud.coinbase.com/advanced-trade-api/docs/sdk-overview + +------------- + +.. toctree:: + :maxdepth: 3 + :caption: Contents: + + coinbase.rest + coinbase.websocket + jwt_generator \ No newline at end of file diff --git a/docs/jwt_generator.rst b/docs/jwt_generator.rst new file mode 100644 index 0000000..1947375 --- /dev/null +++ b/docs/jwt_generator.rst @@ -0,0 +1,7 @@ +Authentication +----------------------------- + +.. automodule:: coinbase.jwt_generator + :members: + :undoc-members: + :show-inheritance: \ No newline at end of file diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..4f8ffbc --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,33 @@ +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs_requirements.txt b/docs_requirements.txt new file mode 100644 index 0000000..e7eee85 --- /dev/null +++ b/docs_requirements.txt @@ -0,0 +1,2 @@ +sphinx==7.2.6 +sphinx_rtd_theme==2.0.0 \ No newline at end of file diff --git a/lint_requirements.txt b/lint_requirements.txt new file mode 100644 index 0000000..a45e8c9 --- /dev/null +++ b/lint_requirements.txt @@ -0,0 +1,2 @@ +black==23.3.0 +isort==5.12.0 \ No newline at end of file diff --git a/pinned_requirements.txt b/pinned_requirements.txt new file mode 100644 index 0000000..59ee7ed --- /dev/null +++ b/pinned_requirements.txt @@ -0,0 +1,5 @@ +requests==2.31.0 +cryptography==42.0.0 +PyJWT==2.8.0 +websockets==12.0 +backoff==2.2.1 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f2dda06 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +requests>=2.31.0 +cryptography>=42.0.0 +PyJWT>=2.8.0 +websockets>=12.0 +backoff>=2.2.1 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..6e9c828 --- /dev/null +++ b/setup.py @@ -0,0 +1,49 @@ +import os + +from setuptools import find_packages, setup + +root = os.path.abspath(os.path.dirname(__file__)) + +with open(os.path.join(root, "requirements.txt"), "r") as fh: + requirements = fh.readlines() + +with open(os.path.join(root, "test_requirements.txt"), "r") as fh: + test_requirements = fh.readlines() + +with open(os.path.join(root, "lint_requirements.txt"), "r") as fh: + lint_requirements = fh.readlines() + +README = open(os.path.join(root, "README.md"), "r").read() + +about = {} + +with open(os.path.join(root, "coinbase", "__version__.py")) as f: + exec(f.read(), about) + +setup( + name="coinbase-advanced-py", + version=about["__version__"], + license="Apache 2.0", + description="Coinbase Advanced API Python SDK", + long_description=README, + long_description_content_type="text/markdown", + author="Coinbase", + url="https://github.com/coinbase/coinbase-advanced-py", + keywords=["Coinbase", "Advanced Trade", "API", "Advanced API"], + packages=find_packages(exclude=("tests",)), + install_requires=[req for req in requirements], + extras_require={ + "test": [test_req for test_req in test_requirements], + "lint": [lint_req for lint_req in lint_requirements], + }, + classifiers=[ + "Intended Audience :: Developers", + "Intended Audience :: Financial and Insurance Industry", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + ], + python_requires=">=3.8", +) diff --git a/test_requirements.txt b/test_requirements.txt new file mode 100644 index 0000000..a3e8fb1 --- /dev/null +++ b/test_requirements.txt @@ -0,0 +1,2 @@ +requests-mock==1.11.0 +asynctest==0.13.0 \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/constants.py b/tests/constants.py new file mode 100644 index 0000000..63cd1e7 --- /dev/null +++ b/tests/constants.py @@ -0,0 +1,2 @@ +TEST_API_KEY = "organizations/test-organization/apiKeys/test-api-key" +TEST_API_SECRET = "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIKwf3Uox30cRWzRBOPoPOH5p0Gpb0Dt8zUKXUEM5fMkGoAoGCCqGSM49\nAwEHoUQDQgAEbAtpLlSZYVOwYICz+uEyxcS29vRIujiES/gQ1DC7FV4zK4JuYE9v\nqDyGZQYjdXHLM7I6f/QnnOITL+dXYWBHRA==\n-----END EC PRIVATE KEY-----\n" diff --git a/tests/rest/__init__.py b/tests/rest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/rest/test_accounts.py b/tests/rest/test_accounts.py new file mode 100644 index 0000000..bd7a94b --- /dev/null +++ b/tests/rest/test_accounts.py @@ -0,0 +1,45 @@ +import unittest + +from requests_mock import Mocker + +from coinbase.rest import RESTClient + +from ..constants import TEST_API_KEY, TEST_API_SECRET + + +class AccountsTest(unittest.TestCase): + def test_get_accounts(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"accounts": [{"uuid": "account1"}, {"name": "account2"}]} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/accounts", + json=expected_response, + ) + accounts = client.get_accounts(limit=2, cursor="abcd") + + captured_request = m.request_history[0] + + self.assertEqual(captured_request.query, "limit=2&cursor=abcd") + self.assertEqual(accounts, expected_response) + + def test_get_account(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"uuid": "account1"} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/accounts/account1", + json=expected_response, + ) + account = client.get_account("account1") + + captured_request = m.request_history[0] + + self.assertEqual(captured_request.query, "") + self.assertEqual(account, expected_response) diff --git a/tests/rest/test_common.py b/tests/rest/test_common.py new file mode 100644 index 0000000..b33af4a --- /dev/null +++ b/tests/rest/test_common.py @@ -0,0 +1,27 @@ +import unittest + +from requests_mock import Mocker + +from coinbase.rest import RESTClient + +from ..constants import TEST_API_KEY, TEST_API_SECRET + + +class TimeTest(unittest.TestCase): + def test_get_time(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"iso": "2022-01-01T00:00:00Z", "epoch": 1640995200} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/time", + json=expected_response, + ) + time = client.get_unix_time() + + captured_request = m.request_history[0] + + self.assertEqual(captured_request.query, "") + self.assertEqual(time, expected_response) diff --git a/tests/rest/test_convert.py b/tests/rest/test_convert.py new file mode 100644 index 0000000..b0e006c --- /dev/null +++ b/tests/rest/test_convert.py @@ -0,0 +1,90 @@ +import unittest + +from requests_mock import Mocker + +from coinbase.rest import RESTClient + +from ..constants import TEST_API_KEY, TEST_API_SECRET + + +class ConvertTest(unittest.TestCase): + def test_create_convert_quote(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"quote_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/convert/quote", + json=expected_response, + ) + quote = client.create_convert_quote( + "from_account", + "to_account", + "100", + user_incentive_id="1234", + code_val="test_val", + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "from_account": "from_account", + "to_account": "to_account", + "amount": "100", + "trade_incentive_metadata": { + "user_incentive_id": "1234", + "code_val": "test_val", + }, + }, + ) + self.assertEqual(quote, expected_response) + + def test_get_convert_trade(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"trade_id": "1234"} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/convert/trade/1234", + json=expected_response, + ) + trade = client.get_convert_trade("1234", "from_account", "to_account") + + captured_request = m.request_history[0] + + self.assertEqual( + captured_request.query, + "from_account=from_account&to_account=to_account", + ) + self.assertEqual(trade, expected_response) + + def test_commit_convert_trade(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"trade_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/convert/trade/1234", + json=expected_response, + ) + trade = client.commit_convert_trade("1234", "from_account", "to_account") + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + {"from_account": "from_account", "to_account": "to_account"}, + ) + self.assertEqual(trade, expected_response) diff --git a/tests/rest/test_fees.py b/tests/rest/test_fees.py new file mode 100644 index 0000000..b39581b --- /dev/null +++ b/tests/rest/test_fees.py @@ -0,0 +1,32 @@ +import unittest + +from requests_mock import Mocker + +from coinbase.rest import RESTClient + +from ..constants import TEST_API_KEY, TEST_API_SECRET + + +class FeesTest(unittest.TestCase): + def test_get_transaction_summary(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/transaction_summary", + json=expected_response, + ) + summary = client.get_transaction_summary( + "product_type", "contract_expiry_type" + ) + + captured_request = m.request_history[0] + + self.assertEqual( + captured_request.query, + "product_type=product_type&contract_expiry_type=contract_expiry_type", + ) + self.assertEqual(summary, expected_response) diff --git a/tests/rest/test_futures.py b/tests/rest/test_futures.py new file mode 100644 index 0000000..733cb95 --- /dev/null +++ b/tests/rest/test_futures.py @@ -0,0 +1,119 @@ +import unittest + +from requests_mock import Mocker + +from coinbase.rest import RESTClient + +from ..constants import TEST_API_KEY, TEST_API_SECRET + + +class FuturesTest(unittest.TestCase): + def test_get_futures_balance_summary(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/cfm/balance_summary", + json=expected_response, + ) + balance_summary = client.get_futures_balance_summary() + + captured_request = m.request_history[0] + + self.assertEqual(captured_request.query, "") + self.assertEqual(balance_summary, expected_response) + + def test_list_futures_positions(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/cfm/positions", + json=expected_response, + ) + positions = client.list_futures_positions() + + captured_request = m.request_history[0] + + self.assertEqual(captured_request.query, "") + self.assertEqual(positions, expected_response) + + def test_get_futures_position(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/cfm/positions/PRODUCT_ID_1", + json=expected_response, + ) + position = client.get_futures_position("PRODUCT_ID_1") + + captured_request = m.request_history[0] + + self.assertEqual(captured_request.query, "") + self.assertEqual(position, expected_response) + + def test_schedule_futures_sweep(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/cfm/sweeps/schedule", + json=expected_response, + ) + response = client.schedule_futures_sweep("5") + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual(captured_json, {"usd_amount": "5"}) + self.assertEqual(response, expected_response) + + def test_list_futures_sweeps(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/cfm/sweeps", + json=expected_response, + ) + sweeps = client.list_futures_sweeps() + + captured_request = m.request_history[0] + + self.assertEqual(captured_request.query, "") + self.assertEqual(sweeps, expected_response) + + def test_cancel_pending_futures_sweep(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "DELETE", + "https://api.coinbase.com/api/v3/brokerage/cfm/sweeps", + json=expected_response, + ) + delete = client.cancel_pending_futures_sweep() + + captured_request = m.request_history[0] + + self.assertEqual(captured_request.query, "") + self.assertEqual(delete, expected_response) diff --git a/tests/rest/test_market_data.py b/tests/rest/test_market_data.py new file mode 100644 index 0000000..717fedb --- /dev/null +++ b/tests/rest/test_market_data.py @@ -0,0 +1,54 @@ +import unittest + +from requests_mock import Mocker + +from coinbase.rest import RESTClient + +from ..constants import TEST_API_KEY, TEST_API_SECRET + + +class MarketDataTest(unittest.TestCase): + def test_get_candles(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/products/product_id_1/candles", + json=expected_response, + ) + candles = client.get_candles( + "product_id_1", "1640995200", "1641081600", "FIVE_MINUTE" + ) + + captured_request = m.request_history[0] + + self.assertEqual( + captured_request.query, + "start=1640995200&end=1641081600&granularity=five_minute", + ) + self.assertEqual(candles, expected_response) + + def test_get_market_trades(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/products/product_id/ticker", + json=expected_response, + ) + trades = client.get_market_trades( + "product_id", 10, "1640995200", "1641081600" + ) + + captured_request = m.request_history[0] + + self.assertEqual( + captured_request.query, "limit=10&start=1640995200&end=1641081600" + ) + self.assertEqual(trades, expected_response) diff --git a/tests/rest/test_orders.py b/tests/rest/test_orders.py new file mode 100644 index 0000000..7932390 --- /dev/null +++ b/tests/rest/test_orders.py @@ -0,0 +1,1404 @@ +import unittest + +from requests_mock import Mocker + +from coinbase.rest import RESTClient + +from ..constants import TEST_API_KEY, TEST_API_SECRET + + +class OrdersTest(unittest.TestCase): + def test_create_order(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders", + json=expected_response, + ) + order_configuration = {"market_market_ioc": {"quote_size": "1"}} + + order = client.create_order( + "client_order_id_1", + "product_id_1", + "BUY", + order_configuration, + self_trade_prevention_id="self_trade_prevention_id_1", + margin_type="CROSS", + leverage="5", + retail_portfolio_id="portfolio_id_1", + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "client_order_id": "client_order_id_1", + "product_id": "product_id_1", + "side": "BUY", + "order_configuration": {"market_market_ioc": {"quote_size": "1"}}, + "self_trade_prevention_id": "self_trade_prevention_id_1", + "margin_type": "CROSS", + "leverage": "5", + "retail_portfolio_id": "portfolio_id_1", + }, + ) + self.assertEqual(order, expected_response) + + def test_market_order(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders", + json=expected_response, + ) + + order = client.market_order( + "client_order_id_1", "product_id_1", "BUY", quote_size="1" + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "client_order_id": "client_order_id_1", + "product_id": "product_id_1", + "side": "BUY", + "order_configuration": {"market_market_ioc": {"quote_size": "1"}}, + }, + ) + self.assertEqual(order, expected_response) + + def test_market_order_buy(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders", + json=expected_response, + ) + + order = client.market_order_buy("client_order_id_1", "product_id_1", "1") + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "client_order_id": "client_order_id_1", + "product_id": "product_id_1", + "side": "BUY", + "order_configuration": {"market_market_ioc": {"quote_size": "1"}}, + }, + ) + self.assertEqual(order, expected_response) + + def test_market_order_sell(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders", + json=expected_response, + ) + + order = client.market_order_sell("client_order_id_1", "product_id_1", "1") + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "client_order_id": "client_order_id_1", + "product_id": "product_id_1", + "side": "SELL", + "order_configuration": {"market_market_ioc": {"base_size": "1"}}, + }, + ) + self.assertEqual(order, expected_response) + + def test_limit_order_gtc(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders", + json=expected_response, + ) + order = client.limit_order_gtc( + "client_order_id_1", + "product_id_1", + "BUY", + "1", + "100", + True, + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "client_order_id": "client_order_id_1", + "product_id": "product_id_1", + "side": "BUY", + "order_configuration": { + "limit_limit_gtc": { + "base_size": "1", + "limit_price": "100", + "post_only": True, + } + }, + }, + ) + self.assertEqual(order, expected_response) + + def test_limit_order_gtc_buy(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders", + json=expected_response, + ) + order = client.limit_order_gtc_buy( + "client_order_id_1", + "product_id_1", + "1", + "100", + True, + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "client_order_id": "client_order_id_1", + "product_id": "product_id_1", + "side": "BUY", + "order_configuration": { + "limit_limit_gtc": { + "base_size": "1", + "limit_price": "100", + "post_only": True, + } + }, + }, + ) + self.assertEqual(order, expected_response) + + def test_limit_order_gtc_sell(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders", + json=expected_response, + ) + order = client.limit_order_gtc_sell( + "client_order_id_1", + "product_id_1", + "1", + "100", + True, + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "client_order_id": "client_order_id_1", + "product_id": "product_id_1", + "side": "SELL", + "order_configuration": { + "limit_limit_gtc": { + "base_size": "1", + "limit_price": "100", + "post_only": True, + } + }, + }, + ) + self.assertEqual(order, expected_response) + + def test_limit_order_gtd(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders", + json=expected_response, + ) + order = client.limit_order_gtd( + "client_order_id_1", + "product_id_1", + "BUY", + "1", + "100", + "2022-01-01T00:00:00Z", + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "client_order_id": "client_order_id_1", + "product_id": "product_id_1", + "side": "BUY", + "order_configuration": { + "limit_limit_gtd": { + "base_size": "1", + "limit_price": "100", + "end_time": "2022-01-01T00:00:00Z", + "post_only": False, + } + }, + }, + ) + self.assertEqual(order, expected_response) + + def test_limit_order_gtd_buy(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders", + json=expected_response, + ) + order = client.limit_order_gtd_buy( + "client_order_id_1", "product_id_1", "1", "100", "2022-01-01T00:00:00Z" + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "client_order_id": "client_order_id_1", + "product_id": "product_id_1", + "side": "BUY", + "order_configuration": { + "limit_limit_gtd": { + "base_size": "1", + "limit_price": "100", + "end_time": "2022-01-01T00:00:00Z", + "post_only": False, + } + }, + }, + ) + self.assertEqual(order, expected_response) + + def test_limit_order_gtd_sell(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders", + json=expected_response, + ) + order = client.limit_order_gtd_sell( + "client_order_id_1", "product_id_1", "1", "100", "2022-01-01T00:00:00Z" + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "client_order_id": "client_order_id_1", + "product_id": "product_id_1", + "side": "SELL", + "order_configuration": { + "limit_limit_gtd": { + "base_size": "1", + "limit_price": "100", + "end_time": "2022-01-01T00:00:00Z", + "post_only": False, + } + }, + }, + ) + self.assertEqual(order, expected_response) + + def test_stop_limit_order_gtc(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders", + json=expected_response, + ) + order = client.stop_limit_order_gtc( + "client_order_id_1", + "product_id_1", + "BUY", + "1", + "100", + "90", + "STOP_DIRECTION_STOP_UP", + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "client_order_id": "client_order_id_1", + "product_id": "product_id_1", + "side": "BUY", + "order_configuration": { + "stop_limit_stop_limit_gtc": { + "base_size": "1", + "limit_price": "100", + "stop_price": "90", + "stop_direction": "STOP_DIRECTION_STOP_UP", + } + }, + }, + ) + self.assertEqual(order, expected_response) + + def test_stop_limit_order_gtc_buy(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders", + json=expected_response, + ) + order = client.stop_limit_order_gtc_buy( + "client_order_id_1", + "product_id_1", + "1", + "100", + "90", + "STOP_DIRECTION_STOP_UP", + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "client_order_id": "client_order_id_1", + "product_id": "product_id_1", + "side": "BUY", + "order_configuration": { + "stop_limit_stop_limit_gtc": { + "base_size": "1", + "limit_price": "100", + "stop_price": "90", + "stop_direction": "STOP_DIRECTION_STOP_UP", + } + }, + }, + ) + self.assertEqual(order, expected_response) + + def test_stop_limit_order_gtc_sell(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders", + json=expected_response, + ) + order = client.stop_limit_order_gtc_sell( + "client_order_id_1", + "product_id_1", + "1", + "100", + "90", + "STOP_DIRECTION_STOP_UP", + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "client_order_id": "client_order_id_1", + "product_id": "product_id_1", + "side": "SELL", + "order_configuration": { + "stop_limit_stop_limit_gtc": { + "base_size": "1", + "limit_price": "100", + "stop_price": "90", + "stop_direction": "STOP_DIRECTION_STOP_UP", + } + }, + }, + ) + self.assertEqual(order, expected_response) + + def test_stop_limit_order_gtd(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders", + json=expected_response, + ) + order = client.stop_limit_order_gtd( + "client_order_id_1", + "product_id_1", + "BUY", + "1", + "100", + "90", + "2022-01-01T00:00:00Z", + "STOP_DIRECTION_STOP_UP", + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "client_order_id": "client_order_id_1", + "product_id": "product_id_1", + "side": "BUY", + "order_configuration": { + "stop_limit_stop_limit_gtd": { + "base_size": "1", + "limit_price": "100", + "stop_price": "90", + "end_time": "2022-01-01T00:00:00Z", + "stop_direction": "STOP_DIRECTION_STOP_UP", + } + }, + }, + ) + self.assertEqual(order, expected_response) + + def test_stop_limit_order_gtd_buy(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders", + json=expected_response, + ) + order = client.stop_limit_order_gtd_buy( + "client_order_id_1", + "product_id_1", + "1", + "100", + "90", + "2022-01-01T00:00:00Z", + "STOP_DIRECTION_STOP_UP", + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "client_order_id": "client_order_id_1", + "product_id": "product_id_1", + "side": "BUY", + "order_configuration": { + "stop_limit_stop_limit_gtd": { + "base_size": "1", + "limit_price": "100", + "stop_price": "90", + "end_time": "2022-01-01T00:00:00Z", + "stop_direction": "STOP_DIRECTION_STOP_UP", + } + }, + }, + ) + self.assertEqual(order, expected_response) + + def test_stop_limit_order_gtd_sell(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders", + json=expected_response, + ) + order = client.stop_limit_order_gtd_sell( + "client_order_id_1", + "product_id_1", + "1", + "100", + "90", + "2022-01-01T00:00:00Z", + "STOP_DIRECTION_STOP_UP", + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "client_order_id": "client_order_id_1", + "product_id": "product_id_1", + "side": "SELL", + "order_configuration": { + "stop_limit_stop_limit_gtd": { + "base_size": "1", + "limit_price": "100", + "stop_price": "90", + "end_time": "2022-01-01T00:00:00Z", + "stop_direction": "STOP_DIRECTION_STOP_UP", + } + }, + }, + ) + self.assertEqual(order, expected_response) + + def test_get_order(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/orders/historical/order_id_1", + json=expected_response, + ) + order = client.get_order("order_id_1") + + captured_request = m.request_history[0] + + self.assertEqual(captured_request.query, "") + self.assertEqual(order, expected_response) + + def test_list_orders(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"orders": [{"order_id": "1234"}, {"order_id": "5678"}]} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/orders/historical/batch", + json=expected_response, + ) + orders = client.list_orders( + product_id="product_id_1", + order_status="OPEN", + limit=2, + product_type="SPOT", + ) + + captured_request = m.request_history[0] + + self.assertEqual( + captured_request.query, + "product_id=product_id_1&order_status=open&limit=2&product_type=spot", + ) + self.assertEqual(orders, expected_response) + + def test_get_fills(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"orders": [{"order_id": "1234"}]} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/orders/historical/fills", + json=expected_response, + ) + orders = client.get_fills( + order_id="1234", product_id="product_id_1", limit=2, cursor="abc" + ) + + captured_request = m.request_history[0] + + self.assertEqual( + captured_request.query, + "order_id=1234&product_id=product_id_1&limit=2&cursor=abc", + ) + self.assertEqual(orders, expected_response) + + def test_edit_order(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "order_id_1"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders/edit", + json=expected_response, + ) + order = client.edit_order("order_id_1", "100", "50") + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, {"order_id": "order_id_1", "size": "100", "price": "50"} + ) + self.assertEqual(order, expected_response) + + def test_preview_edit_order(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "order_id_1"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders/edit_preview", + json=expected_response, + ) + order = client.preview_edit_order("order_id_1", "100", "50") + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, {"order_id": "order_id_1", "size": "100", "price": "50"} + ) + self.assertEqual(order, expected_response) + + def test_cancel_orders(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "order_id_1"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders/batch_cancel", + json=expected_response, + ) + order = client.cancel_orders(["order_id_1", "order_id_2"]) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual(captured_json, {"order_ids": ["order_id_1", "order_id_2"]}) + self.assertEqual(order, expected_response) + + def test_preview_order(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "order_id_1"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders/preview", + json=expected_response, + ) + + order_configuration = {"market_market_ioc": {"quote_size": "1"}} + + preview = client.preview_order( + "product_id_1", + "BUY", + order_configuration, + commission_rate="0.005", + is_max=False, + tradable_balance="100", + skip_fcm_risk_check=False, + leverage="5", + margin_type="CROSS", + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "product_id": "product_id_1", + "side": "BUY", + "order_configuration": {"market_market_ioc": {"quote_size": "1"}}, + "commission_rate": {"value": "0.005"}, + "is_max": False, + "tradable_balance": "100", + "skip_fcm_risk_check": False, + "leverage": "5", + "margin_type": "CROSS", + }, + ) + self.assertEqual(preview, expected_response) + + def test_preview_market_order(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders/preview", + json=expected_response, + ) + + preview = client.preview_market_order("product_id_1", "BUY", quote_size="1") + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "product_id": "product_id_1", + "side": "BUY", + "order_configuration": {"market_market_ioc": {"quote_size": "1"}}, + "is_max": False, + "skip_fcm_risk_check": False, + }, + ) + self.assertEqual(preview, expected_response) + + def test_preview_market_order_buy(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders/preview", + json=expected_response, + ) + + preview = client.preview_market_order_buy("product_id_1", "1") + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "product_id": "product_id_1", + "side": "BUY", + "order_configuration": {"market_market_ioc": {"quote_size": "1"}}, + "is_max": False, + "skip_fcm_risk_check": False, + }, + ) + self.assertEqual(preview, expected_response) + + def test_preview_market_order_sell(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders/preview", + json=expected_response, + ) + + preview = client.preview_market_order_sell("product_id_1", "1") + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "product_id": "product_id_1", + "side": "SELL", + "order_configuration": {"market_market_ioc": {"base_size": "1"}}, + "is_max": False, + "skip_fcm_risk_check": False, + }, + ) + self.assertEqual(preview, expected_response) + + def test_preview_limit_order_gtc(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders/preview", + json=expected_response, + ) + preview = client.preview_limit_order_gtc( + "product_id_1", + "BUY", + "1", + "100", + True, + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "product_id": "product_id_1", + "side": "BUY", + "order_configuration": { + "limit_limit_gtc": { + "base_size": "1", + "limit_price": "100", + "post_only": True, + } + }, + "is_max": False, + "skip_fcm_risk_check": False, + }, + ) + self.assertEqual(preview, expected_response) + + def test_preview_limit_order_gtc_buy(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders/preview", + json=expected_response, + ) + preview = client.preview_limit_order_gtc_buy( + "product_id_1", + "1", + "100", + True, + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "product_id": "product_id_1", + "side": "BUY", + "order_configuration": { + "limit_limit_gtc": { + "base_size": "1", + "limit_price": "100", + "post_only": True, + } + }, + "is_max": False, + "skip_fcm_risk_check": False, + }, + ) + self.assertEqual(preview, expected_response) + + def test_preview_limit_order_gtc_sell(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders/preview", + json=expected_response, + ) + preview = client.preview_limit_order_gtc_sell( + "product_id_1", + "1", + "100", + True, + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "product_id": "product_id_1", + "side": "SELL", + "order_configuration": { + "limit_limit_gtc": { + "base_size": "1", + "limit_price": "100", + "post_only": True, + } + }, + "is_max": False, + "skip_fcm_risk_check": False, + }, + ) + self.assertEqual(preview, expected_response) + + def test_preview_limit_order_gtd(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders/preview", + json=expected_response, + ) + preview = client.preview_limit_order_gtd( + "product_id_1", + "BUY", + "1", + "100", + "2022-01-01T00:00:00Z", + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "product_id": "product_id_1", + "side": "BUY", + "order_configuration": { + "limit_limit_gtd": { + "base_size": "1", + "limit_price": "100", + "end_time": "2022-01-01T00:00:00Z", + "post_only": False, + } + }, + "is_max": False, + "skip_fcm_risk_check": False, + }, + ) + self.assertEqual(preview, expected_response) + + def test_preview_limit_order_gtd_buy(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders/preview", + json=expected_response, + ) + preview = client.preview_limit_order_gtd_buy( + "product_id_1", "1", "100", "2022-01-01T00:00:00Z" + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "product_id": "product_id_1", + "side": "BUY", + "order_configuration": { + "limit_limit_gtd": { + "base_size": "1", + "limit_price": "100", + "end_time": "2022-01-01T00:00:00Z", + "post_only": False, + } + }, + "is_max": False, + "skip_fcm_risk_check": False, + }, + ) + self.assertEqual(preview, expected_response) + + def test_preview_limit_order_gtd_sell(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders/preview", + json=expected_response, + ) + preview = client.preview_limit_order_gtd_sell( + "product_id_1", "1", "100", "2022-01-01T00:00:00Z" + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "product_id": "product_id_1", + "side": "SELL", + "order_configuration": { + "limit_limit_gtd": { + "base_size": "1", + "limit_price": "100", + "end_time": "2022-01-01T00:00:00Z", + "post_only": False, + } + }, + "is_max": False, + "skip_fcm_risk_check": False, + }, + ) + self.assertEqual(preview, expected_response) + + def test_preview_stop_limit_order_gtc(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders/preview", + json=expected_response, + ) + preview = client.preview_stop_limit_order_gtc( + "product_id_1", + "BUY", + "1", + "100", + "90", + "STOP_DIRECTION_STOP_UP", + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "product_id": "product_id_1", + "side": "BUY", + "order_configuration": { + "stop_limit_stop_limit_gtc": { + "base_size": "1", + "limit_price": "100", + "stop_price": "90", + "stop_direction": "STOP_DIRECTION_STOP_UP", + } + }, + "is_max": False, + "skip_fcm_risk_check": False, + }, + ) + self.assertEqual(preview, expected_response) + + def test_preview_stop_limit_order_gtc_buy(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders/preview", + json=expected_response, + ) + preview = client.preview_stop_limit_order_gtc_buy( + "product_id_1", + "1", + "100", + "90", + "STOP_DIRECTION_STOP_UP", + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "product_id": "product_id_1", + "side": "BUY", + "order_configuration": { + "stop_limit_stop_limit_gtc": { + "base_size": "1", + "limit_price": "100", + "stop_price": "90", + "stop_direction": "STOP_DIRECTION_STOP_UP", + } + }, + "is_max": False, + "skip_fcm_risk_check": False, + }, + ) + self.assertEqual(preview, expected_response) + + def test_preview_stop_limit_order_gtc_sell(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders/preview", + json=expected_response, + ) + preview = client.preview_stop_limit_order_gtc_sell( + "product_id_1", + "1", + "100", + "90", + "STOP_DIRECTION_STOP_UP", + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "product_id": "product_id_1", + "side": "SELL", + "order_configuration": { + "stop_limit_stop_limit_gtc": { + "base_size": "1", + "limit_price": "100", + "stop_price": "90", + "stop_direction": "STOP_DIRECTION_STOP_UP", + } + }, + "is_max": False, + "skip_fcm_risk_check": False, + }, + ) + self.assertEqual(preview, expected_response) + + def test_preview_stop_limit_order_gtd(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders/preview", + json=expected_response, + ) + preview = client.preview_stop_limit_order_gtd( + "product_id_1", + "BUY", + "1", + "100", + "90", + "2022-01-01T00:00:00Z", + "STOP_DIRECTION_STOP_UP", + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "product_id": "product_id_1", + "side": "BUY", + "order_configuration": { + "stop_limit_stop_limit_gtd": { + "base_size": "1", + "limit_price": "100", + "stop_price": "90", + "end_time": "2022-01-01T00:00:00Z", + "stop_direction": "STOP_DIRECTION_STOP_UP", + } + }, + "is_max": False, + "skip_fcm_risk_check": False, + }, + ) + self.assertEqual(preview, expected_response) + + def test_preview_stop_limit_order_gtd_buy(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders/preview", + json=expected_response, + ) + preview = client.preview_stop_limit_order_gtd_buy( + "product_id_1", + "1", + "100", + "90", + "2022-01-01T00:00:00Z", + "STOP_DIRECTION_STOP_UP", + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "product_id": "product_id_1", + "side": "BUY", + "order_configuration": { + "stop_limit_stop_limit_gtd": { + "base_size": "1", + "limit_price": "100", + "stop_price": "90", + "end_time": "2022-01-01T00:00:00Z", + "stop_direction": "STOP_DIRECTION_STOP_UP", + } + }, + "is_max": False, + "skip_fcm_risk_check": False, + }, + ) + self.assertEqual(preview, expected_response) + + def test_preview_stop_limit_order_gtd_sell(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"order_id": "1234"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/orders/preview", + json=expected_response, + ) + preview = client.preview_stop_limit_order_gtd_sell( + "product_id_1", + "1", + "100", + "90", + "2022-01-01T00:00:00Z", + "STOP_DIRECTION_STOP_UP", + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "product_id": "product_id_1", + "side": "SELL", + "order_configuration": { + "stop_limit_stop_limit_gtd": { + "base_size": "1", + "limit_price": "100", + "stop_price": "90", + "end_time": "2022-01-01T00:00:00Z", + "stop_direction": "STOP_DIRECTION_STOP_UP", + } + }, + "is_max": False, + "skip_fcm_risk_check": False, + }, + ) + self.assertEqual(preview, expected_response) diff --git a/tests/rest/test_perpetuals.py b/tests/rest/test_perpetuals.py new file mode 100644 index 0000000..5da35a9 --- /dev/null +++ b/tests/rest/test_perpetuals.py @@ -0,0 +1,98 @@ +import unittest + +from requests_mock import Mocker + +from coinbase.rest import RESTClient + +from ..constants import TEST_API_KEY, TEST_API_SECRET + + +class PerpetualsTest(unittest.TestCase): + def test_allocate_portfolio(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/intx/allocate", + json=expected_response, + ) + response = client.allocate_portfolio( + portfolio_uuid="test_uuid", + symbol="BTC-PERP-INTX", + amount="100", + currency="USD", + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "portfolio_uuid": "test_uuid", + "symbol": "BTC-PERP-INTX", + "amount": "100", + "currency": "USD", + }, + ) + self.assertEqual(response, expected_response) + + def test_get_perps_portfolio_summary(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/intx/portfolio/test_uuid", + json=expected_response, + ) + portfolios = client.get_perps_portfolio_summary(portfolio_uuid="test_uuid") + + captured_request = m.request_history[0] + + self.assertEqual(captured_request.query, "") + self.assertEqual(portfolios, expected_response) + + def test_list_perps_positions(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/intx/positions/test_uuid", + json=expected_response, + ) + portfolios = client.list_perps_positions(portfolio_uuid="test_uuid") + + captured_request = m.request_history[0] + + self.assertEqual(captured_request.query, "") + self.assertEqual(portfolios, expected_response) + + def test_get_perps_position(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/intx/positions/test_uuid/BTC-PERP-INTX", + json=expected_response, + ) + portfolios = client.get_perps_position( + portfolio_uuid="test_uuid", symbol="BTC-PERP-INTX" + ) + + captured_request = m.request_history[0] + + self.assertEqual(captured_request.query, "") + self.assertEqual(portfolios, expected_response) diff --git a/tests/rest/test_portfolios.py b/tests/rest/test_portfolios.py new file mode 100644 index 0000000..d960735 --- /dev/null +++ b/tests/rest/test_portfolios.py @@ -0,0 +1,130 @@ +import unittest + +from requests_mock import Mocker + +from coinbase.rest import RESTClient + +from ..constants import TEST_API_KEY, TEST_API_SECRET + + +class PortfoliosTest(unittest.TestCase): + def test_get_portfolios(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/portfolios", + json=expected_response, + ) + portfolios = client.get_portfolios("DEFAULT") + + captured_request = m.request_history[0] + + self.assertEqual(captured_request.query, "portfolio_type=default") + self.assertEqual(portfolios, expected_response) + + def test_create_portfolio(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/portfolios", + json=expected_response, + ) + portfolio = client.create_portfolio("Test Portfolio") + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual(captured_json, {"name": "Test Portfolio"}) + self.assertEqual(portfolio, expected_response) + + def test_get_portfolio_breakdown(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/portfolios/1234", + json=expected_response, + ) + breakdown = client.get_portfolio_breakdown("1234") + + captured_request = m.request_history[0] + + self.assertEqual(captured_request.query, "") + self.assertEqual(breakdown, expected_response) + + def test_move_portfolio_funds(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/portfolios/move_funds", + json=expected_response, + ) + move = client.move_portfolio_funds("100", "USD", "1234", "5678") + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual( + captured_json, + { + "funds": {"value": "100", "currency": "USD"}, + "source_portfolio_uuid": "1234", + "target_portfolio_uuid": "5678", + }, + ) + self.assertEqual(move, expected_response) + + def test_edit_portfolio(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "PUT", + "https://api.coinbase.com/api/v3/brokerage/portfolios/1234", + json=expected_response, + ) + edit = client.edit_portfolio("1234", "Test Portfolio") + + captured_request = m.request_history[0] + captured_json = captured_request.json() + + self.assertEqual(captured_request.query, "") + self.assertEqual(captured_json, {"name": "Test Portfolio"}) + self.assertEqual(edit, expected_response) + + def test_delete_portfolio(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "DELETE", + "https://api.coinbase.com/api/v3/brokerage/portfolios/1234", + json=expected_response, + ) + delete = client.delete_portfolio("1234") + + captured_request = m.request_history[0] + + self.assertEqual(captured_request.query, "") + self.assertEqual(delete, expected_response) diff --git a/tests/rest/test_products.py b/tests/rest/test_products.py new file mode 100644 index 0000000..cac983b --- /dev/null +++ b/tests/rest/test_products.py @@ -0,0 +1,83 @@ +import unittest + +from requests_mock import Mocker + +from coinbase.rest import RESTClient + +from ..constants import TEST_API_KEY, TEST_API_SECRET + + +class ProductsTest(unittest.TestCase): + def test_get_products(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/products", + json=expected_response, + ) + products = client.get_products(limit=2, product_type="SPOT") + + captured_request = m.request_history[0] + + self.assertEqual(captured_request.query, "limit=2&product_type=spot") + self.assertEqual(products, expected_response) + + def test_get_product(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"product_id": "product_1"} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/products/product_1", + json=expected_response, + ) + product = client.get_product("product_1") + + captured_request = m.request_history[0] + + self.assertEqual(captured_request.query, "") + self.assertEqual(product, expected_response) + + def test_get_product_book(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/product_book", + json=expected_response, + ) + book = client.get_product_book("product_1", 10) + + captured_request = m.request_history[0] + + self.assertEqual(captured_request.query, "product_id=product_1&limit=10") + self.assertEqual(book, expected_response) + + def test_get_best_bid_ask(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/best_bid_ask", + json=expected_response, + ) + bid_ask = client.get_best_bid_ask(["product_1", "product_2"]) + + captured_request = m.request_history[0] + + self.assertEqual( + captured_request.query, "product_ids=product_1&product_ids=product_2" + ) + self.assertEqual(bid_ask, expected_response) diff --git a/tests/rest/test_rest_base.py b/tests/rest/test_rest_base.py new file mode 100644 index 0000000..07dc3d7 --- /dev/null +++ b/tests/rest/test_rest_base.py @@ -0,0 +1,172 @@ +import unittest + +from requests.exceptions import HTTPError +from requests_mock import Mocker + +from coinbase.__version__ import __version__ +from coinbase.rest import RESTClient + +from ..constants import TEST_API_KEY, TEST_API_SECRET + + +class RestBaseTest(unittest.TestCase): + def test_get(self): + client = RESTClient(api_key=TEST_API_KEY, api_secret=TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/accounts", + json=expected_response, + ) + + params = {"limit": 2} + kwargs = {"test_kwarg": "test"} + accounts = client.get("/api/v3/brokerage/accounts", params, **kwargs) + + captured_request = m.request_history[0] + captured_query = captured_request.query + captured_headers = captured_request.headers + + self.assertEqual(captured_request.method, "GET") + + self.assertEqual(captured_query, "limit=2&test_kwarg=test") + + self.assertTrue("User-Agent" in captured_headers) + self.assertEqual( + captured_headers["User-Agent"], "coinbase-advanced-py/" + __version__ + ) + self.assertTrue("Authorization" in captured_headers) + self.assertTrue(captured_headers["Authorization"].startswith("Bearer ")) + + self.assertEqual(accounts, expected_response) + + def test_post(self): + client = RESTClient(api_key=TEST_API_KEY, api_secret=TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "POST", + "https://api.coinbase.com/api/v3/brokerage/portfolios", + json=expected_response, + ) + + data = {"name": "TestName"} + kwargs = {"test_kwarg": "test"} + + portfolio = client.post("/api/v3/brokerage/portfolios", data=data, **kwargs) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + captured_headers = captured_request.headers + + self.assertEqual(captured_request.method, "POST") + + self.assertEqual(captured_json, {"name": "TestName", "test_kwarg": "test"}) + + self.assertTrue("User-Agent" in captured_headers) + self.assertEqual( + captured_headers["User-Agent"], "coinbase-advanced-py/" + __version__ + ) + self.assertTrue("Authorization" in captured_headers) + self.assertTrue(captured_headers["Authorization"].startswith("Bearer ")) + + self.assertEqual(portfolio, expected_response) + + def test_put(self): + client = RESTClient(api_key=TEST_API_KEY, api_secret=TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "PUT", + "https://api.coinbase.com/api/v3/brokerage/portfolios/1234", + json=expected_response, + ) + + data = {"name": "TestName"} + kwargs = {"test_kwarg": "test"} + + portfolio = client.put( + "/api/v3/brokerage/portfolios/1234", data=data, **kwargs + ) + + captured_request = m.request_history[0] + captured_json = captured_request.json() + captured_headers = captured_request.headers + + self.assertEqual(captured_request.method, "PUT") + + self.assertEqual(captured_json, {"name": "TestName", "test_kwarg": "test"}) + + self.assertTrue("User-Agent" in captured_headers) + self.assertEqual( + captured_headers["User-Agent"], "coinbase-advanced-py/" + __version__ + ) + self.assertTrue("Authorization" in captured_headers) + self.assertTrue(captured_headers["Authorization"].startswith("Bearer ")) + + self.assertEqual(portfolio, expected_response) + + def test_delete(self): + client = RESTClient(api_key=TEST_API_KEY, api_secret=TEST_API_SECRET) + + expected_response = {"key_1": "value_1", "key_2": "value_2"} + + with Mocker() as m: + m.request( + "DELETE", + "https://api.coinbase.com/api/v3/brokerage/portfolios/1234", + json=expected_response, + ) + + kwargs = {"test_kwarg": "test"} + + portfolio = client.delete("/api/v3/brokerage/portfolios/1234", **kwargs) + + captured_request = m.request_history[0] + captured_headers = captured_request.headers + + self.assertEqual(captured_request.method, "DELETE") + + self.assertEqual(captured_request.json(), kwargs) + + self.assertTrue("User-Agent" in captured_headers) + self.assertEqual( + captured_headers["User-Agent"], "coinbase-advanced-py/" + __version__ + ) + self.assertTrue("Authorization" in captured_headers) + self.assertTrue(captured_headers["Authorization"].startswith("Bearer ")) + + self.assertEqual(portfolio, expected_response) + + def test_client_error(self): + client = RESTClient(api_key=TEST_API_KEY, api_secret=TEST_API_SECRET) + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/accounts", + status_code=400, + ) + + with self.assertRaises(HTTPError): + client.get("/api/v3/brokerage/accounts") + + def test_server_error(self): + client = RESTClient(api_key=TEST_API_KEY, api_secret=TEST_API_SECRET) + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/accounts", + status_code=500, + ) + + with self.assertRaises(HTTPError): + client.get("/api/v3/brokerage/accounts") diff --git a/tests/test_api_base.py b/tests/test_api_base.py new file mode 100644 index 0000000..f0fefb9 --- /dev/null +++ b/tests/test_api_base.py @@ -0,0 +1,57 @@ +import unittest +from io import StringIO + +from coinbase.api_base import APIBase + +from .constants import TEST_API_KEY, TEST_API_SECRET + + +class RestBaseTest(unittest.TestCase): + def test_no_api_key(self): + with self.assertRaises(Exception): + APIBase(None, None) + + with self.assertRaises(Exception): + APIBase("test_key", None) + + def test_key_api_key_vars(self): + try: + APIBase(api_key=TEST_API_KEY, api_secret=TEST_API_SECRET) + except Exception as e: + self.fail(f"An unexpected exception occurred: {e}") + + def test_key_file_string(self): + try: + APIBase(key_file="tests/test_api_key.json") + except Exception as e: + self.fail(f"An unexpected exception occurred: {e}") + + def test_key_file_object(self): + try: + key_file_object = StringIO( + '{"name": "test-api-key-name","privateKey": "test-api-key-private-key"}' + ) + APIBase(key_file=key_file_object) + except Exception as e: + self.fail(f"An unexpected exception occurred: {e}") + + def test_key_file_no_key(self): + with self.assertRaises(Exception): + key_file_object = StringIO('{"field_1": "value_1","field_2": "value_2"}') + APIBase(key_file=key_file_object) + + def test_key_file_multiple_key_inputs(self): + with self.assertRaises(Exception): + key_file_object = StringIO('{"field_1": "value_1","field_2": "value_2"}') + APIBase( + api_key=TEST_API_KEY, + api_secret=TEST_API_SECRET, + key_file=key_file_object, + ) + + def test_key_file_invalid_json(self): + with self.assertRaises(Exception): + key_file_object = StringIO( + '"name": "test-api-key-name","privateKey": "test-api-key-private-key"' + ) + APIBase(key_file=key_file_object) diff --git a/tests/test_api_key.json b/tests/test_api_key.json new file mode 100644 index 0000000..ab7b71c --- /dev/null +++ b/tests/test_api_key.json @@ -0,0 +1,13 @@ +{ + "name": "test-api-key-name", + "privateKey": "test-api-key-private-key", + "nickname": "TestApiKey", + "scopes": [ + "rat#view", + "rat#trade", + "rat#transfer" + ], + "allowedIps": [], + "keyType": "TRADING_KEY", + "enabled": true +} \ No newline at end of file diff --git a/tests/test_jwt_generator.py b/tests/test_jwt_generator.py new file mode 100644 index 0000000..3e9e6ac --- /dev/null +++ b/tests/test_jwt_generator.py @@ -0,0 +1,48 @@ +import base64 +import json +import unittest + +import jwt + +from coinbase import jwt_generator +from coinbase.constants import REST_SERVICE, WS_SERVICE + +from .constants import TEST_API_KEY, TEST_API_SECRET + + +class JwtGeneratorTest(unittest.TestCase): + def test_build_rest_jwt(self): + uri = jwt_generator.format_jwt_uri("GET", "/api/v3/brokerage/accounts") + result_jwt = jwt_generator.build_rest_jwt(uri, TEST_API_KEY, TEST_API_SECRET) + + decoded_data = jwt.decode( + result_jwt, TEST_API_SECRET, algorithms=["ES256"], audience=[REST_SERVICE] + ) + header_bytes = base64.urlsafe_b64decode(str(result_jwt.split(".")[0] + "==")) + decoded_header = json.loads(header_bytes.decode("utf-8")) + + self.assertEqual(decoded_data["sub"], TEST_API_KEY) + self.assertEqual(decoded_data["iss"], "coinbase-cloud") + self.assertEqual(decoded_data["aud"], [REST_SERVICE]) + self.assertEqual(decoded_data["uri"], uri) + self.assertEqual(decoded_header["kid"], TEST_API_KEY) + + def test_build_ws_jwt(self): + result_jwt = jwt_generator.build_ws_jwt(TEST_API_KEY, TEST_API_SECRET) + + decoded_data = jwt.decode( + result_jwt, TEST_API_SECRET, algorithms=["ES256"], audience=[WS_SERVICE] + ) + header_bytes = base64.urlsafe_b64decode(str(result_jwt.split(".")[0] + "==")) + decoded_header = json.loads(header_bytes.decode("utf-8")) + + self.assertEqual(decoded_data["sub"], TEST_API_KEY) + self.assertEqual(decoded_data["iss"], "coinbase-cloud") + self.assertEqual(decoded_data["aud"], [WS_SERVICE]) + self.assertNotIn("uri", decoded_data) + self.assertEqual(decoded_header["kid"], TEST_API_KEY) + + def test_build_rest_jwt_error(self): + with self.assertRaises(Exception): + uri = jwt_generator.format_jwt_uri("GET", "/api/v3/brokerage/accounts") + jwt_generator.build_rest_jwt(uri, TEST_API_KEY, "bad_secret") diff --git a/tests/websocket/__init__.py b/tests/websocket/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/websocket/mock_ws_server.py b/tests/websocket/mock_ws_server.py new file mode 100644 index 0000000..beea62d --- /dev/null +++ b/tests/websocket/mock_ws_server.py @@ -0,0 +1,70 @@ +import asyncio +from collections import namedtuple + +import websockets + + +class MockWebSocketServer: + def __init__(self, host="localhost", port=8765): + self.start_server = None + self.host = host + self.port = port + self.server = None + self.active_websockets = set() + + async def handler(self, websocket): + self.active_websockets.add(websocket) + try: + async for message in websocket: + # Echo the message back to the client + await websocket.send(message) + except websockets.ConnectionClosed: + pass + finally: + self.active_websockets.discard(websocket) + + def initialize_server(self): + return websockets.serve(self.handler, self.host, self.port) + + async def start(self): + self.start_server = self.initialize_server() + self.server = await self.start_server + + async def stop(self): + WebSocketTask = namedtuple("WebSocketTask", ["ws", "task"]) + + tasks = [ + WebSocketTask(ws, asyncio.create_task(ws.close())) + for ws in self.active_websockets + ] + await asyncio.gather(*(task.task for task in tasks)) + self.active_websockets -= {task.ws for task in tasks} + + if self.server: + self.server.close() + await self.server.wait_closed() # Ensure the server is fully closed + + async def restart_with_error(self): + await self.trigger_connection_closed_error() + await self.stop() + await asyncio.sleep(1) # Short delay to ensure the port is freed up + await self.start() + + async def trigger_connection_closed_error(self): + WebSocketTask = namedtuple("WebSocketTask", ["ws", "task"]) + + tasks = [ + WebSocketTask( + ws, asyncio.create_task(ws.close(code=4000, reason="Abnormal closure")) + ) + for ws in self.active_websockets + ] + await asyncio.gather(*(task.task for task in tasks)) + self.active_websockets -= {task.ws for task in tasks} + + +# Function to start the mock server +async def start_mock_server(): + server = MockWebSocketServer() + await server.start() + return server diff --git a/tests/websocket/test_channels.py b/tests/websocket/test_channels.py new file mode 100644 index 0000000..36c2f24 --- /dev/null +++ b/tests/websocket/test_channels.py @@ -0,0 +1,204 @@ +import asyncio +import json +import time +import unittest +from unittest.mock import AsyncMock, patch + +import websockets + +from coinbase.constants import ( + CANDLES, + HEARTBEATS, + LEVEL2, + MARKET_TRADES, + STATUS, + TICKER, + TICKER_BATCH, + USER, +) +from coinbase.websocket import WSClient + +from ..constants import TEST_API_KEY, TEST_API_SECRET + + +class WSBaseTest(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.on_message_mock = unittest.mock.Mock() + + # set up mock websocket messages + connection_closed_exception = websockets.ConnectionClosedOK( + 1000, "Normal closure", False + ) + self.mock_websocket = AsyncMock() + self.mock_websocket.recv = AsyncMock( + side_effect=[ + connection_closed_exception, + ] + ) + + # initialize client + self.ws = WSClient( + TEST_API_KEY, TEST_API_SECRET, on_message=self.on_message_mock + ) + + @patch("websockets.connect", new_callable=AsyncMock) + def generic_channel_test( + self, channel_func, channel_func_unsub, channel_const, mock_connect + ): + # assert you can subscribe and unsubscribe to a channel + mock_connect.return_value = self.mock_websocket + + # open + self.ws.open() + self.assertIsNotNone(self.ws.websocket) + + # subscribe + channel_func(product_ids=["BTC-USD", "ETH-USD"]) + self.mock_websocket.send.assert_awaited_once() + + # assert subscribe message + subscribe = json.loads(self.mock_websocket.send.call_args_list[0][0][0]) + self.assertEqual(subscribe["type"], "subscribe") + self.assertEqual(subscribe["product_ids"], ["BTC-USD", "ETH-USD"]) + self.assertEqual(subscribe["channel"], channel_const) + + # unsubscribe + channel_func_unsub(product_ids=["BTC-USD", "ETH-USD"]) + self.assertEqual(self.mock_websocket.send.await_count, 2) + + # assert unsubscribe message + unsubscribe = json.loads(self.mock_websocket.send.call_args_list[1][0][0]) + self.assertEqual(unsubscribe["type"], "unsubscribe") + self.assertEqual(unsubscribe["product_ids"], ["BTC-USD", "ETH-USD"]) + self.assertEqual(unsubscribe["channel"], channel_const) + + # close + self.ws.close() + self.mock_websocket.close.assert_awaited_once() + + @patch("websockets.connect", new_callable=AsyncMock) + async def generic_channel_test_async( + self, channel_func, channel_func_unsub, channel_const, mock_connect + ): + # assert you can subscribe and unsubscribe to a channel + mock_connect.return_value = self.mock_websocket + + # open + await self.ws.open_async() + self.assertIsNotNone(self.ws.websocket) + + # subscribe + await channel_func(product_ids=["BTC-USD", "ETH-USD"]) + self.mock_websocket.send.assert_awaited_once() + + # assert subscribe message + subscribe = json.loads(self.mock_websocket.send.call_args_list[0][0][0]) + self.assertEqual(subscribe["type"], "subscribe") + self.assertEqual(subscribe["product_ids"], ["BTC-USD", "ETH-USD"]) + self.assertEqual(subscribe["channel"], channel_const) + + # unsubscribe + await channel_func_unsub(product_ids=["BTC-USD", "ETH-USD"]) + self.assertEqual(self.mock_websocket.send.await_count, 2) + + # assert unsubscribe message + unsubscribe = json.loads(self.mock_websocket.send.call_args_list[1][0][0]) + self.assertEqual(unsubscribe["type"], "unsubscribe") + self.assertEqual(unsubscribe["product_ids"], ["BTC-USD", "ETH-USD"]) + self.assertEqual(unsubscribe["channel"], channel_const) + + # close + await self.ws.close_async() + self.mock_websocket.close.assert_awaited_once() + + def test_heartbeats(self): + self.generic_channel_test( + self.ws.heartbeats, self.ws.heartbeats_unsubscribe, HEARTBEATS + ) + + def test_heartbeats_async(self): + asyncio.run( + self.generic_channel_test_async( + self.ws.heartbeats_async, + self.ws.heartbeats_unsubscribe_async, + HEARTBEATS, + ) + ) + + def test_candles(self): + self.generic_channel_test(self.ws.candles, self.ws.candles_unsubscribe, CANDLES) + + def test_candles_async(self): + asyncio.run( + self.generic_channel_test_async( + self.ws.candles_async, self.ws.candles_unsubscribe_async, CANDLES + ) + ) + + def test_level2(self): + self.generic_channel_test(self.ws.level2, self.ws.level2_unsubscribe, LEVEL2) + + def test_level2_async(self): + asyncio.run( + self.generic_channel_test_async( + self.ws.level2_async, self.ws.level2_unsubscribe_async, LEVEL2 + ) + ) + + def test_market_trades(self): + self.generic_channel_test( + self.ws.market_trades, self.ws.market_trades_unsubscribe, MARKET_TRADES + ) + + def test_market_trades_async(self): + asyncio.run( + self.generic_channel_test_async( + self.ws.market_trades_async, + self.ws.market_trades_unsubscribe_async, + MARKET_TRADES, + ) + ) + + def test_status(self): + self.generic_channel_test(self.ws.status, self.ws.status_unsubscribe, STATUS) + + def test_status_async(self): + asyncio.run( + self.generic_channel_test_async( + self.ws.status_async, self.ws.status_unsubscribe_async, STATUS + ) + ) + + def test_ticker(self): + self.generic_channel_test(self.ws.ticker, self.ws.ticker_unsubscribe, TICKER) + + def test_ticker_async(self): + asyncio.run( + self.generic_channel_test_async( + self.ws.ticker_async, self.ws.ticker_unsubscribe_async, TICKER + ) + ) + + def test_ticker_batch(self): + self.generic_channel_test( + self.ws.ticker_batch, self.ws.ticker_batch_unsubscribe, TICKER_BATCH + ) + + def test_ticker_batch_async(self): + asyncio.run( + self.generic_channel_test_async( + self.ws.ticker_batch_async, + self.ws.ticker_batch_unsubscribe_async, + TICKER_BATCH, + ) + ) + + def test_user(self): + self.generic_channel_test(self.ws.user, self.ws.user_unsubscribe, USER) + + def test_user_async(self): + asyncio.run( + self.generic_channel_test_async( + self.ws.user_async, self.ws.user_unsubscribe_async, USER + ) + ) diff --git a/tests/websocket/test_websocket_base.py b/tests/websocket/test_websocket_base.py new file mode 100644 index 0000000..eb3ff6d --- /dev/null +++ b/tests/websocket/test_websocket_base.py @@ -0,0 +1,380 @@ +import asyncio +import json +import unittest +from unittest.mock import AsyncMock, patch + +import websockets + +from coinbase.constants import SUBSCRIBE_MESSAGE_TYPE, UNSUBSCRIBE_MESSAGE_TYPE +from coinbase.websocket import ( + WSClient, + WSClientConnectionClosedException, + WSClientException, +) + +from ..constants import TEST_API_KEY, TEST_API_SECRET +from . import mock_ws_server + + +class WSBaseTest(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + # set the event when on_message_mock is called + self.message_received_event = asyncio.Event() + self.on_message_mock = unittest.mock.Mock( + side_effect=lambda message: self.message_received_event.set() + ) + + # set up mock websocket messages + connection_closed_exception = websockets.ConnectionClosedOK( + 1000, "Normal closure", False + ) + self.mock_websocket = AsyncMock() + self.mock_websocket.recv = AsyncMock( + side_effect=[ + "test message", + connection_closed_exception, + connection_closed_exception, + ] + ) + + # initialize client + self.ws = WSClient( + TEST_API_KEY, + TEST_API_SECRET, + on_message=self.on_message_mock, + retry=False, + ) + + @patch("websockets.connect", new_callable=AsyncMock) + def test_open_twice(self, mock_connect): + # assert you cannot open a websocket client twice consecutively + mock_connect.return_value = self.mock_websocket + + self.ws.open() + self.assertIsNotNone(self.ws.websocket) + + with self.assertRaises(Exception): + self.ws.open() + + @patch("websockets.connect", new_callable=AsyncMock) + def test_err_after_close(self, mock_connect): + # assert you cannot close a websocket client twice consecutively + mock_connect.return_value = self.mock_websocket + + self.ws.open() + self.assertIsNotNone(self.ws.websocket) + + self.ws.close() + + # assert you cannot close a websocket client twice consecutively + with self.assertRaises(Exception): + self.ws.close() + + # assert you cannot message a websocket client after closing + with self.assertRaises(Exception): + self.ws.subscribe(product_ids=["BTC-USD"], channels=["ticker"]) + + with self.assertRaises(Exception): + self.ws.unsubscribe(product_ids=["BTC-USD"], channels=["ticker"]) + + def test_err_unopened(self): + # assert you cannot close an unopened websocket client + with self.assertRaises(Exception): + self.ws.close() + + # assert you cannot message an unopened websocket client + with self.assertRaises(Exception): + self.ws.subscribe(product_ids=["BTC-USD"], channels=["ticker"]) + + with self.assertRaises(Exception): + self.ws.unsubscribe(product_ids=["BTC-USD"], channels=["ticker"]) + + @patch("websockets.connect", new_callable=AsyncMock) + def test_open_and_close(self, mock_connect): + # assert you can open and close a websocket client + mock_connect.return_value = self.mock_websocket + + # open + self.ws.open() + self.assertIsNotNone(self.ws.websocket) + + # assert on_message received + self.on_message_mock.assert_called_once_with("test message") + + # close + self.ws.close() + self.mock_websocket.close.assert_awaited_once() + + @patch("websockets.connect", new_callable=AsyncMock) + def test_reopen(self, mock_connect): + # assert you can open, close, reopen and close a websocket client + mock_connect.return_value = self.mock_websocket + + # open + self.ws.open() + self.assertIsNotNone(self.ws.websocket) + + # close + self.ws.close() + self.mock_websocket.close.assert_awaited_once() + + # reopen + self.ws.open() + self.assertIsNotNone(self.ws.websocket) + self.assertTrue(self.ws.websocket.open) + + # close + self.ws.close() + self.assertEqual(self.mock_websocket.close.await_count, 2) + + @patch("websockets.connect", new_callable=AsyncMock) + def test_subscribe_and_unsubscribe_channel(self, mock_connect): + # assert you can subscribe and unsubscribe to a channel + mock_connect.return_value = self.mock_websocket + + # open + self.ws.open() + self.assertIsNotNone(self.ws.websocket) + + # subscribe + self.ws.subscribe(product_ids=["BTC-USD", "ETH-USD"], channels=["ticker"]) + self.mock_websocket.send.assert_awaited_once() + + # assert subscribe message + subscribe = json.loads(self.mock_websocket.send.call_args_list[0][0][0]) + self.assertEqual(subscribe["type"], SUBSCRIBE_MESSAGE_TYPE) + self.assertEqual(subscribe["product_ids"], ["BTC-USD", "ETH-USD"]) + self.assertEqual(subscribe["channel"], "ticker") + + # unsubscribe + self.ws.unsubscribe(product_ids=["BTC-USD", "ETH-USD"], channels=["ticker"]) + self.assertEqual(self.mock_websocket.send.await_count, 2) + + # assert unsubscribe message + unsubscribe = json.loads(self.mock_websocket.send.call_args_list[1][0][0]) + self.assertEqual(unsubscribe["type"], UNSUBSCRIBE_MESSAGE_TYPE) + self.assertEqual(unsubscribe["product_ids"], ["BTC-USD", "ETH-USD"]) + self.assertEqual(unsubscribe["channel"], "ticker") + + # close + self.ws.close() + self.mock_websocket.close.assert_awaited_once() + + @patch("websockets.connect", new_callable=AsyncMock) + def test_subscribe_and_unsubscribe_channels(self, mock_connect): + # assert you can subscribe and unsubscribe to multiple channels + mock_connect.return_value = self.mock_websocket + + # open + self.ws.open() + self.assertIsNotNone(self.ws.websocket) + + # subscribe + self.ws.subscribe( + product_ids=["BTC-USD", "ETH-USD"], channels=["ticker", "level2"] + ) + self.assertEqual(self.mock_websocket.send.await_count, 2) + + # assert subscribe messages + subscribe_1 = json.loads(self.mock_websocket.send.call_args_list[0][0][0]) + self.assertEqual(subscribe_1["type"], SUBSCRIBE_MESSAGE_TYPE) + self.assertEqual(subscribe_1["product_ids"], ["BTC-USD", "ETH-USD"]) + self.assertEqual(subscribe_1["channel"], "ticker") + + subscribe_2 = json.loads(self.mock_websocket.send.call_args_list[1][0][0]) + self.assertEqual(subscribe_2["type"], SUBSCRIBE_MESSAGE_TYPE) + self.assertEqual(subscribe_2["product_ids"], ["BTC-USD", "ETH-USD"]) + self.assertEqual(subscribe_2["channel"], "level2") + + # unsubscribe + self.ws.unsubscribe( + product_ids=["BTC-USD", "ETH-USD"], channels=["ticker", "level2"] + ) + self.assertEqual(self.mock_websocket.send.await_count, 4) + + # assert unsubscribe messages + unsubscribe_1 = json.loads(self.mock_websocket.send.call_args_list[2][0][0]) + self.assertEqual(unsubscribe_1["type"], UNSUBSCRIBE_MESSAGE_TYPE) + self.assertEqual(unsubscribe_1["product_ids"], ["BTC-USD", "ETH-USD"]) + self.assertEqual(unsubscribe_1["channel"], "ticker") + + unsubscribe_2 = json.loads(self.mock_websocket.send.call_args_list[3][0][0]) + self.assertEqual(unsubscribe_2["type"], UNSUBSCRIBE_MESSAGE_TYPE) + self.assertEqual(unsubscribe_2["product_ids"], ["BTC-USD", "ETH-USD"]) + self.assertEqual(unsubscribe_2["channel"], "level2") + + # close + self.ws.close() + self.mock_websocket.close.assert_awaited_once() + + @patch("websockets.connect", new_callable=AsyncMock) + async def test_open_and_close_async(self, mock_connect): + # assert you can open and close a websocket client + mock_connect.return_value = self.mock_websocket + + # open + await self.ws.open_async() + self.assertIsNotNone(self.ws.websocket) + + # assert on_message received + await self.message_received_event.wait() + self.on_message_mock.assert_called_once_with("test message") + + # close + await self.ws.close_async() + self.mock_websocket.close.assert_awaited_once() + + @patch("websockets.connect", new_callable=AsyncMock) + async def test_reopen_async(self, mock_connect): + # assert you can open, close, reopen and close a websocket client + mock_connect.return_value = self.mock_websocket + + # open + await self.ws.open_async() + self.assertIsNotNone(self.ws.websocket) + + # close + await self.ws.close_async() + self.mock_websocket.close.assert_awaited_once() + + # reopen + await self.ws.open_async() + self.assertIsNotNone(self.ws.websocket) + self.assertTrue(self.ws.websocket.open) + + # close + await self.ws.close_async() + self.assertEqual(self.mock_websocket.close.await_count, 2) + + @patch("websockets.connect", new_callable=AsyncMock) + async def test_subscribe_and_unsubscribes_channel_async(self, mock_connect): + # assert you can subscribe and unsubscribe to a channel + mock_connect.return_value = self.mock_websocket + + # open + await self.ws.open_async() + self.assertIsNotNone(self.ws.websocket) + + # subscribe + await self.ws.subscribe_async( + product_ids=["BTC-USD", "ETH-USD"], channels=["ticker"] + ) + self.mock_websocket.send.assert_awaited_once() + + # assert subscribe message + subscribe = json.loads(self.mock_websocket.send.call_args_list[0][0][0]) + self.assertEqual(subscribe["type"], SUBSCRIBE_MESSAGE_TYPE) + self.assertEqual(subscribe["product_ids"], ["BTC-USD", "ETH-USD"]) + self.assertEqual(subscribe["channel"], "ticker") + + # unsubscribe + await self.ws.unsubscribe_async( + product_ids=["BTC-USD", "ETH-USD"], channels=["ticker"] + ) + self.assertEqual(self.mock_websocket.send.await_count, 2) + + # assert unsubscribe message + unsubscribe = json.loads(self.mock_websocket.send.call_args_list[1][0][0]) + self.assertEqual(unsubscribe["type"], UNSUBSCRIBE_MESSAGE_TYPE) + self.assertEqual(unsubscribe["product_ids"], ["BTC-USD", "ETH-USD"]) + self.assertEqual(unsubscribe["channel"], "ticker") + + # close + await self.ws.close_async() + self.mock_websocket.close.assert_awaited_once() + + +class WSDisconnectionTests(unittest.IsolatedAsyncioTestCase): + # tests that run against a mock websocket server to simulate disconnections + async def mock_send(self, message): + self.messages_queue.put_nowait(message) + + async def asyncSetUp(self): + self.messages_queue = asyncio.Queue() + self.server = await mock_ws_server.start_mock_server() + + def on_message(msg): + self.messages_queue.put_nowait(msg) + + self.ws = WSClient( + TEST_API_KEY, + TEST_API_SECRET, + base_url="ws://localhost:8765", + on_message=on_message, + retry=False, + ) + + # self.ws._retry_base = 1 + # self.ws._retry_factor = 1.5 + # self.ws._retry_max = 5 + + async def asyncTearDown(self): + await self.server.stop() + + async def test_disconnect_error(self): + # tests that client can catch a WSClientConnectionClosedException + + # open ws connection + await self.ws.open_async() + + # trigger connection closed error + await self.server.trigger_connection_closed_error() + + # Check for background exceptions + with self.assertRaises(WSClientConnectionClosedException): + await self.ws.run_forever_with_exception_check_async() + + async def test_reconnect(self): + # tests that client can automatically reconnect after a WSClientConnectionClosedException + + self.ws.retry = True + + # Open WebSocket connection + await self.ws.open_async() + await self.ws.subscribe_async( + product_ids=["BTC-USD", "ETH-USD"], channels=["ticker"] + ) + + await self.messages_queue.get() + await self.ws.subscribe_async(product_ids=["BTC-USD"], channels=["heartbeats"]) + await self.messages_queue.get() + + # disconnect and restart the server + await self.server.restart_with_error() + + # assert resubscribe messages + resubscribe_1 = await self.messages_queue.get() + resubscribe_1_json = json.loads(resubscribe_1) + self.assertEqual(resubscribe_1_json["type"], SUBSCRIBE_MESSAGE_TYPE) + self.assertEqual( + sorted(resubscribe_1_json["product_ids"]), ["BTC-USD", "ETH-USD"] + ) + self.assertEqual(resubscribe_1_json["channel"], "ticker") + + resubscribe_2 = await self.messages_queue.get() + resubscribe_2_json = json.loads(resubscribe_2) + self.assertEqual(resubscribe_2_json["type"], SUBSCRIBE_MESSAGE_TYPE) + self.assertEqual(resubscribe_2_json["product_ids"], ["BTC-USD"]) + self.assertEqual(resubscribe_2_json["channel"], "heartbeats") + + async def test_reconnect_fail(self): + # tests that client can catch WSClientConnectionClosedException after failed reconnection + self.ws.retry = True + self.ws._retry_max_tries = 1 + + # Open WebSocket connection + await self.ws.open_async() + await self.ws.subscribe_async( + product_ids=["BTC-USD", "ETH-USD"], channels=["ticker"] + ) + + await self.messages_queue.get() + await self.ws.subscribe_async(product_ids=["BTC-USD"], channels=["heartbeats"]) + await self.messages_queue.get() + + with self.assertRaises(WSClientConnectionClosedException): + # disconnect and restart the server + await self.server.restart_with_error() + + # assert that client throws exception if it cannot reconnect + await self.ws.run_forever_with_exception_check_async()