From 9a4071563d711190f7d07d9673e9776699190555 Mon Sep 17 00:00:00 2001 From: Uri Schwartz Date: Thu, 7 Dec 2023 14:01:48 -0500 Subject: [PATCH 01/21] initial commit --- .gitignore | 56 +++ LICENSE.md | 201 +++++++++ Makefile | 5 + README.md | 123 ++++++ coinbase/__init__.py | 0 coinbase/__version__.py | 1 + coinbase/constants.py | 6 + coinbase/jwt_generator.py | 44 ++ coinbase/rest/__init__.py | 41 ++ coinbase/rest/accounts.py | 37 ++ coinbase/rest/common.py | 16 + coinbase/rest/convert.py | 77 ++++ coinbase/rest/fees.py | 29 ++ coinbase/rest/market_data.py | 50 +++ coinbase/rest/orders.py | 777 +++++++++++++++++++++++++++++++++++ coinbase/rest/portfolios.py | 117 ++++++ coinbase/rest/products.py | 91 ++++ coinbase/rest/rest_base.py | 125 ++++++ requirements.txt | 5 + setup.py | 22 + 20 files changed, 1823 insertions(+) create mode 100644 .gitignore 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/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/market_data.py create mode 100644 coinbase/rest/orders.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 requirements.txt create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1082ab8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# 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 + +# 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 + +test.py \ 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..4865f3a --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +.PHONY: format +format: + @echo "Formatting code..." + isort . + black . \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea2f3e7 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# Coinbase Advanced Trading API Python SDK + +This is a work in progress README. + +Welcome to the official Coinbase Advanced Trading API Python SDK. This python project was created to allow coders to easily plug into the [Coinbase Advanced Trade API](https://docs.cloud.coinbase.com/advanced-trade-api/docs/welcome). + +## Installation + +To install, please clone this git repo, cd into the root and run: +```bash +pip install . +``` + +## 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. You can do this by following the instructions [here](Todo--add link here). +Make sure to save your API key and secret in a safe place. You will not be able to retrieve your secret again. + +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 + +client = RESTClient(api_key="", api_secret="") +``` +You can also set a timeout in seconds for your REST requests like so: +```python +from coinbase.rest import RESTClient + +client = RESTClient(api_key="", api_secret="", timeout=5) +``` + +### Using the Client + +You can then 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("clientOrderId", "BTC-USD", "1") +print(dumps(order, indent=2)) +``` +This code calls the `get_accounts` and `market_order_buy` endpoints. + +You can refer to the [Advanced Trade API Reference](https://docs.cloud.coinbase.com/advanced-trade-api/reference) for detailed information on each exposed endpoint. +You can look at the following [mapping](Todo--add link here) to see which API hook corresponds to which endpoint. + + +### Passing in additional parameters +You can 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 also 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](Todo--add link) 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("BTC-USD", 5) + +portfolio = client.create_portfolio("TestPortfolio") +``` + +## Authentication +Authentication of Cloud API Keys is handled automatically by the SDK when making a REST request. + +However, if you wish to handle this yourself, you must create a JWT token as detailed in the Cloud API docs [here](Todo--add link). You can 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. You can then 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) +``` +You can use this JWT to connect to the Websocket API by setting it in the "jwt" field of your subscription requests. See the docs [here](Todo--add link) for more details. + +## Contributing + +If you've found a bug within this project, please open an issue on this repo and add the "bug" label to it. +If you would like to request a new feature, please open an issue on this repo and add the "enhancement" label to it. +Please direct concerns or questions on the API to the [Advanced Trade 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..5becc17 --- /dev/null +++ b/coinbase/__version__.py @@ -0,0 +1 @@ +__version__ = "1.0.0" diff --git a/coinbase/constants.py b/coinbase/constants.py new file mode 100644 index 0000000..9212550 --- /dev/null +++ b/coinbase/constants.py @@ -0,0 +1,6 @@ +BASE_URL = "api.coinbase.com" +API_PREFIX = "/api/v3/brokerage" +REST_SERVICE = "retail_rest_api_proxy" +WS_SERVICE = "public_websocket_api" +API_ENV_KEY = "COINBASE_API_KEY" +API_SECRET_ENV_KEY = "COINBASE_API_SECRET" diff --git a/coinbase/jwt_generator.py b/coinbase/jwt_generator.py new file mode 100644 index 0000000..4930047 --- /dev/null +++ b/coinbase/jwt_generator.py @@ -0,0 +1,44 @@ +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): + private_key_bytes = secret_var.encode("utf-8") + private_key = serialization.load_pem_private_key(private_key_bytes, password=None) + print(private_key) + + jwt_data = { + "sub": key_var, + "iss": "coinbase-cloud", + "nbf": int(time.time()), + "exp": int(time.time()) + 60, + "aud": [service], + } + + if uri: + jwt_data["uri"] = uri + + jwt_token = jwt.encode( + jwt_data, + private_key, + algorithm="ES256", + headers={"kid": key_var, "nonce": str(int(time.time()))}, + ) + + return jwt_token + + +def build_rest_jwt(uri, key_var, secret_var): + return build_jwt(key_var, secret_var, REST_SERVICE, uri=uri) + + +def build_ws_jwt(key_var, secret_var): + return build_jwt(key_var, secret_var, WS_SERVICE) + + +def format_jwt_uri(method, path): + return f"{method} {BASE_URL}{path}" diff --git a/coinbase/rest/__init__.py b/coinbase/rest/__init__.py new file mode 100755 index 0000000..6d76e95 --- /dev/null +++ b/coinbase/rest/__init__.py @@ -0,0 +1,41 @@ +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 .market_data import get_candles, get_market_trades + from .orders import ( + cancel_orders, + 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, + 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 .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..2398dca --- /dev/null +++ b/coinbase/rest/accounts.py @@ -0,0 +1,37 @@ +from typing import Optional + +from coinbase.constants import API_PREFIX + + +def get_accounts( + self, limit: Optional[int] = None, cursor: Optional[str] = None, **kwargs +): + """ + Get a list of authenticated accounts for the current user. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getaccounts + """ + endpoint = f"{API_PREFIX}/accounts" + params = {"limit": limit, "cursor": cursor} + + # Filter out None values from the params dictionary + if kwargs: + params.update(kwargs) + params = {key: value for key, value in params.items() if value is not None} + + return self.get(endpoint, params=params) + + +def get_account(self, account_uuid: str, **kwargs): + """ + Get a list of information about an account, given an account UUID. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getaccount + """ + endpoint = f"{API_PREFIX}/accounts/{account_uuid}" + + params = {} + if kwargs: + params.update(kwargs) + + return self.get(endpoint, params=params) diff --git a/coinbase/rest/common.py b/coinbase/rest/common.py new file mode 100644 index 0000000..760cffc --- /dev/null +++ b/coinbase/rest/common.py @@ -0,0 +1,16 @@ +from coinbase.constants import API_PREFIX + + +def get_unix_time(self, **kwargs): + """ + Get the current time from the Coinbase Advanced API. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getunixtime + """ + endpoint = f"{API_PREFIX}/time" + + params = {} + if kwargs: + params.update(kwargs) + + return self.get(endpoint, params=params) diff --git a/coinbase/rest/convert.py b/coinbase/rest/convert.py new file mode 100644 index 0000000..62ef35c --- /dev/null +++ b/coinbase/rest/convert.py @@ -0,0 +1,77 @@ +from typing import 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, +): + """ + Create a convert quote with a specified source currency, target currency, and amount. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_createconvertquote + """ + 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, + }, + } + + if kwargs: + data.update(kwargs) + + return self.post(endpoint, data=data) + + +def get_convert_trade( + self, trade_id: str, from_account: str, to_account: str, **kwargs +): + """ + Gets a list of information about a convert trade with a specified trade ID, source currency, and target currency. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getconverttrade + """ + endpoint = f"{API_PREFIX}/convert/trade/{trade_id}" + + params = { + "from_account": from_account, + "to_account": to_account, + } + + if kwargs: + params.update(kwargs) + + return self.get(endpoint, params=params) + + +def commit_convert_trade( + self, trade_id: str, from_account: str, to_account: str, **kwargs +): + """ + Commits a convert trade with a specified trade ID, source currency, and target currency. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_commitconverttrade + """ + endpoint = f"{API_PREFIX}/convert/trade/{trade_id}" + + data = { + "from_account": from_account, + "to_account": to_account, + } + + if kwargs: + data.update(kwargs) + + return self.post(endpoint, data=data) diff --git a/coinbase/rest/fees.py b/coinbase/rest/fees.py new file mode 100644 index 0000000..5360c88 --- /dev/null +++ b/coinbase/rest/fees.py @@ -0,0 +1,29 @@ +from typing import Optional + +from coinbase.constants import API_PREFIX + + +def get_transaction_summary( + self, + product_type: Optional[str] = None, + contract_expiry_type: Optional[str] = None, + **kwargs, +): + """ + Get a summary of transactions with fee tiers, total volume, and fees. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_gettransactionsummary + """ + endpoint = f"{API_PREFIX}/transaction_summary" + + params = { + "product_type": product_type, + "contract_expiry_type": contract_expiry_type, + } + + # Filter out None values from the params dictionary + if kwargs: + params.update(kwargs) + params = {key: value for key, value in params.items() if value is not None} + + return self.get(endpoint, params=params) diff --git a/coinbase/rest/market_data.py b/coinbase/rest/market_data.py new file mode 100644 index 0000000..5eaf5b3 --- /dev/null +++ b/coinbase/rest/market_data.py @@ -0,0 +1,50 @@ +from typing import Optional + +from coinbase.constants import API_PREFIX + + +def get_candles( + self, product_id: str, start: str, end: str, granularity: str, **kwargs +): + """ + Get rates for a single product by product ID, grouped in buckets. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getcandles + """ + endpoint = f"{API_PREFIX}/products/{product_id}/candles" + + params = { + "start": start, + "end": end, + "granularity": granularity, + } + + if kwargs: + params.update(kwargs) + + return self.get(endpoint, params=params) + + +def get_market_trades( + self, + product_id: str, + limit: int, + start: Optional[str] = None, + end: Optional[str] = None, + **kwargs, +): + """ + Get snapshot information, by product ID, about the last trades (ticks), best bid/ask, and 24h volume. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getmarkettrades + """ + endpoint = f"{API_PREFIX}/products/{product_id}/ticker" + + params = {"limit": limit, "start": start, "end": end} + + # Filter out None values from the params dictionary + if kwargs: + params.update(kwargs) + params = {key: value for key, value in params.items() if value is not None} + + return self.get(endpoint, params=params) diff --git a/coinbase/rest/orders.py b/coinbase/rest/orders.py new file mode 100644 index 0000000..b2a0366 --- /dev/null +++ b/coinbase/rest/orders.py @@ -0,0 +1,777 @@ +from typing import 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, +): + """ + Create an order with a specified product_id (asset-pair), side (buy/sell), etc. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + """ + 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, + } + + if kwargs: + data.update(kwargs) + + return self.post(endpoint, data=data) + + +# 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, +): + """ + 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. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + """ + order_configuration = { + "market_market_ioc": {"quote_size": quote_size, "base_size": base_size} + } + + 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, +): + """ + Place a market order to BUY the desired product at the given market price. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + """ + 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, +): + """ + Place a market order to SELL the desired product at the given market price. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + """ + 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, +): + """ + 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. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + """ + 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, +): + """ + 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. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + """ + 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, +): + """ + 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. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + """ + 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, +): + """ + 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. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + """ + 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, +): + """ + 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. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + """ + 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, +): + """ + 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. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + """ + 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, +): + """ + 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. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + """ + 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, +): + """ + 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. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + """ + 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, +): + """ + 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. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + """ + 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, +): + """ + 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. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + """ + 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, +): + """ + 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. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + """ + 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, +): + """ + 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. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + """ + 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): + """ + Get a single order by order ID. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_gethistoricalorder + """ + endpoint = f"{API_PREFIX}/orders/historical/{order_id}" + + params = {} + if kwargs: + params.update(kwargs) + + return self.get(endpoint, params=params) + + +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, +): + """ + Get a list of orders filtered by optional query parameters (product_id, order_status, etc). + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_gethistoricalorders + """ + 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, + } + + # Filter out None values from the params dictionary + if kwargs: + params.update(kwargs) + params = {key: value for key, value in params.items() if value is not None} + + return self.get(endpoint, params=params) + + +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, +): + """ + Get a list of fills filtered by optional query parameters (product_id, order_id, etc). + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getfills + """ + 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, + } + + # Filter out None values from the params dictionary + if kwargs: + params.update(kwargs) + params = {key: value for key, value in params.items() if value is not None} + + return self.get(endpoint, params=params) + + +def edit_order( + self, + order_id: str, + size: Optional[str] = None, + price: Optional[str] = None, + **kwargs, +): + """ + 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. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_editorder + """ + endpoint = f"{API_PREFIX}/orders/edit" + data = { + "order_id": order_id, + "size": size, + "price": price, + } + + if kwargs: + data.update(kwargs) + + return self.post(endpoint, data=data) + + +def preview_edit_order( + self, + order_id: str, + size: Optional[str] = None, + price: Optional[str] = None, + **kwargs, +): + """ + 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. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_previeweditorder + """ + endpoint = f"{API_PREFIX}/orders/edit_preview" + data = { + "order_id": order_id, + "size": size, + "price": price, + } + + if kwargs: + data.update(kwargs) + + return self.post(endpoint, data=data) + + +def cancel_orders(self, order_ids: List[str], **kwargs): + """ + Initiate cancel requests for one or more orders. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_cancelorders + """ + endpoint = f"{API_PREFIX}/orders/batch_cancel" + data = { + "order_ids": order_ids, + } + + if kwargs: + data.update(kwargs) + + return self.post(endpoint, data=data) diff --git a/coinbase/rest/portfolios.py b/coinbase/rest/portfolios.py new file mode 100644 index 0000000..eef1c9f --- /dev/null +++ b/coinbase/rest/portfolios.py @@ -0,0 +1,117 @@ +from typing import Optional + +from coinbase.constants import API_PREFIX + + +def get_portfolios(self, portfolio_type: Optional[str] = None, **kwargs): + """ + Get a list of all portfolios of a user. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getportfolios + """ + endpoint = f"{API_PREFIX}/portfolios" + + params = {"portfolio_type": portfolio_type} + + # Filter out None values from the params dictionary + if kwargs: + params.update(kwargs) + params = {key: value for key, value in params.items() if value is not None} + + return self.get(endpoint, params=params) + + +def create_portfolio(self, name: str, **kwargs): + """ + Create a portfolio. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_createportfolio + """ + endpoint = f"{API_PREFIX}/portfolios" + + data = { + "name": name, + } + + if kwargs: + data.update(kwargs) + + return self.post(endpoint, data=data) + + +def get_portfolio_breakdown(self, portfolio_uuid: str, **kwargs): + """ + Get the breakdown of a portfolio by portfolio ID. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getportfoliobreakdown + """ + endpoint = f"{API_PREFIX}/portfolios/{portfolio_uuid}" + + params = {} + if kwargs: + params.update(kwargs) + + return self.get(endpoint, params=params) + + +def move_portfolio_funds( + self, + value: str, + currency: str, + source_portfolio_uuid: str, + target_portfolio_uuid: str, + **kwargs, +): + """ + Transfer funds between portfolios. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_moveportfoliofunds + """ + endpoint = f"{API_PREFIX}/portfolios/move_funds" + + data = { + "funds": { + "amount": value, + "currency": currency, + }, + "source_portfolio_id": source_portfolio_uuid, + "target_portfolio_id": target_portfolio_uuid, + } + + if kwargs: + data.update(kwargs) + + return self.post(endpoint, data=data) + + +def edit_portfolio(self, portfolio_uuid: str, name: str, **kwargs): + """ + Modify a portfolio by portfolio ID. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_editportfolio + """ + endpoint = f"{API_PREFIX}/portfolios/{portfolio_uuid}" + + data = { + "name": name, + } + + if kwargs: + data.update(kwargs) + + return self.put(endpoint, data=data) + + +def delete_portfolio(self, portfolio_uuid: str, **kwargs): + """ + Delete a portfolio by portfolio ID. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_deleteportfolio + """ + endpoint = f"{API_PREFIX}/portfolios/{portfolio_uuid}" + + data = {} + if kwargs: + data.update(kwargs) + + return self.delete(endpoint, data=data) diff --git a/coinbase/rest/products.py b/coinbase/rest/products.py new file mode 100644 index 0000000..8ac5a08 --- /dev/null +++ b/coinbase/rest/products.py @@ -0,0 +1,91 @@ +from typing import 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, +): + """ + Get a list of the available currency pairs for trading. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getproducts + """ + 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, + } + + # Filter out None values from the params dictionary + if kwargs: + params.update(kwargs) + params = {key: value for key, value in params.items() if value is not None} + + return self.get(endpoint, params=params) + + +def get_product(self, product_id: str, **kwargs): + """ + Get information on a single product by product ID. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getproduct + """ + endpoint = f"{API_PREFIX}/products/{product_id}" + + params = {} + if kwargs: + params.update(kwargs) + + return self.get(endpoint, params=params) + + +def get_product_book(self, product_id: str, limit: Optional[int] = None, **kwargs): + """ + Get a list of bids/asks for a single product. The amount of detail shown can be customized with the limit parameter. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getproductbook + """ + endpoint = f"{API_PREFIX}/product_book" + + params = {"product_id": product_id, "limit": limit} + + # Filter out None values from the params dictionary + if kwargs: + params.update(kwargs) + params = {key: value for key, value in params.items() if value is not None} + + return self.get(endpoint, params=params) + + +def get_best_bid_ask(self, product_ids: Optional[List[str]] = None, **kwargs): + """ + Get the best bid/ask for all products. A subset of all products can be returned instead by using the product_ids + input. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getbestbidask + """ + endpoint = f"{API_PREFIX}/best_bid_ask" + + params = { + "product_ids": product_ids, + } + + # Filter out None values from the params dictionary + if kwargs: + params.update(kwargs) + params = {key: value for key, value in params.items() if value is not None} + + return self.get(endpoint, params=params) diff --git a/coinbase/rest/rest_base.py b/coinbase/rest/rest_base.py new file mode 100644 index 0000000..3b79783 --- /dev/null +++ b/coinbase/rest/rest_base.py @@ -0,0 +1,125 @@ +import json +import os +from typing import Optional + +import requests +from requests.exceptions import HTTPError + +from coinbase import jwt_generator +from coinbase.__version__ import __version__ +from coinbase.constants import API_ENV_KEY, API_SECRET_ENV_KEY, BASE_URL + + +def prepare_params(params): + if params is None: + return None + + def encode_value(key, value): + if isinstance(value, list): + return "&".join(f"{key}={v}" for v in value) + else: + return f"{key}={value}" + + return "&".join(encode_value(key, value) for key, value in params.items()) + + +def encode(data): + if data is None: + return None + return json.dumps(data).encode("utf-8") + + +def handle_exception(response): + """Raises :class:`HTTPError`, if one occurred.""" + + http_error_msg = "" + reason = response.reason + + if 400 <= response.status_code < 500: + 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: + raise HTTPError(http_error_msg, response=response) + + +class RESTBase(object): + def __init__( + self, + api_key: Optional[str] = os.getenv(API_ENV_KEY), + api_secret: Optional[str] = os.getenv(API_SECRET_ENV_KEY), + base_url=BASE_URL, + timeout=None, + ): + 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 + + def get(self, url_path, params: Optional[dict] = None): + 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 + ): + return self.prepare_and_send_request("POST", url_path, params, data) + + def put(self, url_path, params: Optional[dict] = None, data: Optional[dict] = None): + return self.prepare_and_send_request("PUT", url_path, params, data) + + def delete( + self, url_path, params: Optional[dict] = None, data: Optional[dict] = None + ): + 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, + ): + headers = self.set_headers(http_method, url_path) + + params_string = prepare_params(params) + if params_string: + url_path = f"{url_path}?{params_string}" + + data_encoded = encode(data) + return self.send_request(http_method, url_path, headers, data=data_encoded) + + def send_request(self, http_method, url_path, headers, data=None): + if data is None: + data = {} + + url = f"https://{self.base_url}{url_path}" + + response = requests.request( + http_method, url, data=data, headers=headers, timeout=self.timeout + ) + handle_exception(response) # Raise an HTTPError for bad responses + + return response.json() + + def set_headers(self, method, path): + 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": "Bearer " + jwt, + "User-Agent": "coinbase-advanced-py/" + __version__, + } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..47b30cf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +requests +black>=23.3.0 +isort>=5.11.5 +cryptography>=41.0.5 +PyJWT>=2.8.0 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..74b2c71 --- /dev/null +++ b/setup.py @@ -0,0 +1,22 @@ +import os + +from setuptools import find_packages, setup + +with open(os.path.join(os.path.dirname(__file__), "requirements.txt"), "r") as fh: + requirements = fh.readlines() + +about = {} + +root = os.path.abspath(os.path.dirname(__file__)) + +with open(os.path.join(root, "coinbase", "__version__.py")) as f: + exec(f.read(), about) + +setup( + name="coinbase-advanced-py", + version=about["__version__"], + AUTHOR="Coinbase", + packages=find_packages(), + install_requires=[req for req in requirements], + python_requires=">=3.8", +) From 7908d98e841360a28cdf760d95c55de3538062c0 Mon Sep 17 00:00:00 2001 From: urischwartz-cb <143205923+urischwartz-cb@users.noreply.github.com> Date: Thu, 7 Dec 2023 14:29:58 -0500 Subject: [PATCH 02/21] inital commit fix (#2) --- coinbase/jwt_generator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/coinbase/jwt_generator.py b/coinbase/jwt_generator.py index 4930047..67fb9c4 100644 --- a/coinbase/jwt_generator.py +++ b/coinbase/jwt_generator.py @@ -9,7 +9,6 @@ def build_jwt(key_var, secret_var, service, uri=None): private_key_bytes = secret_var.encode("utf-8") private_key = serialization.load_pem_private_key(private_key_bytes, password=None) - print(private_key) jwt_data = { "sub": key_var, From 4f7f7ddbf4898e635b0ca135591ba7c92c7a9657 Mon Sep 17 00:00:00 2001 From: urischwartz-cb <143205923+urischwartz-cb@users.noreply.github.com> Date: Wed, 13 Dec 2023 17:04:42 -0500 Subject: [PATCH 03/21] Cleanup and add Unit tests (#4) * cleanup and add unit tests * test on ubuntu --- .github/workflows/build.yml | 47 ++ .gitignore | 1 + README.md | 12 +- coinbase/rest/__init__.py | 1 + coinbase/rest/accounts.py | 2 - coinbase/rest/convert.py | 17 +- coinbase/rest/fees.py | 2 - coinbase/rest/market_data.py | 2 - coinbase/rest/orders.py | 12 +- coinbase/rest/portfolios.py | 2 - coinbase/rest/products.py | 6 - coinbase/rest/rest_base.py | 39 +- setup.py | 6 + test_requirements.txt | 1 + tests/__init__.py | 0 tests/constants.py | 2 + tests/rest/__init__.py | 0 tests/rest/test_accounts.py | 44 ++ tests/rest/test_common.py | 26 ++ tests/rest/test_convert.py | 79 ++++ tests/rest/test_fees.py | 31 ++ tests/rest/test_market_data.py | 53 +++ tests/rest/test_orders.py | 769 +++++++++++++++++++++++++++++++++ tests/rest/test_portfolios.py | 129 ++++++ tests/rest/test_products.py | 82 ++++ tests/rest/test_rest_base.py | 73 ++++ tests/test_jwt_generator.py | 42 ++ 27 files changed, 1423 insertions(+), 57 deletions(-) create mode 100644 .github/workflows/build.yml 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_market_data.py create mode 100644 tests/rest/test_orders.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_jwt_generator.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..c9d97ef --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,47 @@ +name: Build and Test + +on: [push] + +jobs: + format-code: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Install dependencies and format code + run: | + sudo apt-get install -y make + pip3 install black isort + make format + + if git diff --quiet; then + echo "No code formatting changes detected." + else + echo "Code formatting changes detected. Please run 'make format' locally and commit the changes." + git diff --exit-code + exit 1 + fi + + run-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python environment + uses: actions/setup-python@v3 + with: + python-version: 3.9 + + - name: Install dependencies and run tests + run: | + python -m venv venv + source venv/bin/activate + pip install --upgrade pip + pip install -r requirements.txt + pip install -r test_requirements.txt + + python -m unittest discover -v diff --git a/.gitignore b/.gitignore index 1082ab8..48ccc61 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ var/ *.egg-info/ .installed.cfg *.egg +*.DS_Store # PyInstaller # Usually these files are written by a python script from a template diff --git a/README.md b/README.md index ea2f3e7..ded382c 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,17 @@ # Coinbase Advanced Trading API Python SDK -This is a work in progress README. - Welcome to the official Coinbase Advanced Trading API Python SDK. This python project was created to allow coders to easily plug into the [Coinbase Advanced Trade API](https://docs.cloud.coinbase.com/advanced-trade-api/docs/welcome). ## Installation To install, please clone this git repo, cd into the root and run: ```bash -pip install . +pip3 install . ``` ## 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. You can do this by following the instructions [here](Todo--add link here). +This SDK uses the Coinbase Cloud API keys. To use this SDK, you will need to create a Coinbase Cloud API key and secret. You can do this 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. Optional: Set your API key and secret in your environment (make sure to put these in quotation marks). For example: @@ -77,7 +75,7 @@ market_trades = client.get("/api/v3/brokerage/products/BTC-USD/ticker", params={ 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](Todo--add link) endpoints through the generic REST functions. +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("BTC-USD", 5) @@ -88,7 +86,7 @@ portfolio = client.create_portfolio("TestPortfolio") ## Authentication Authentication of Cloud API Keys is handled automatically by the SDK when making a REST request. -However, if you wish to handle this yourself, you must create a JWT token as detailed in the Cloud API docs [here](Todo--add link). You can use the built in `jwt_generator` to create your JWT token. For example: +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). You can use the built in `jwt_generator` to create your JWT token. For example: ```python from coinbase import jwt_generator @@ -114,7 +112,7 @@ api_secret = "-----BEGIN EC PRIVATE KEY-----\nYOUR PRIVATE KEY\n-----END EC PRIV jwt = jwt_generator.build_ws_jwt(api_key, api_secret) ``` -You can use this JWT to connect to the Websocket API by setting it in the "jwt" field of your subscription requests. See the docs [here](Todo--add link) for more details. +You can 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. ## Contributing diff --git a/coinbase/rest/__init__.py b/coinbase/rest/__init__.py index 6d76e95..8fb39ff 100755 --- a/coinbase/rest/__init__.py +++ b/coinbase/rest/__init__.py @@ -9,6 +9,7 @@ class RESTClient(RESTBase): from .market_data import get_candles, get_market_trades from .orders import ( cancel_orders, + create_order, edit_order, get_fills, get_order, diff --git a/coinbase/rest/accounts.py b/coinbase/rest/accounts.py index 2398dca..b1353f0 100644 --- a/coinbase/rest/accounts.py +++ b/coinbase/rest/accounts.py @@ -14,10 +14,8 @@ def get_accounts( endpoint = f"{API_PREFIX}/accounts" params = {"limit": limit, "cursor": cursor} - # Filter out None values from the params dictionary if kwargs: params.update(kwargs) - params = {key: value for key, value in params.items() if value is not None} return self.get(endpoint, params=params) diff --git a/coinbase/rest/convert.py b/coinbase/rest/convert.py index 62ef35c..909cc34 100644 --- a/coinbase/rest/convert.py +++ b/coinbase/rest/convert.py @@ -23,12 +23,21 @@ def create_convert_quote( "from_account": from_account, "to_account": to_account, "amount": amount, - "trade_incentive_metadata": { - "user_incentive_id": user_incentive_id, - "code_val": code_val, - }, } + 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 + if kwargs: data.update(kwargs) diff --git a/coinbase/rest/fees.py b/coinbase/rest/fees.py index 5360c88..d561212 100644 --- a/coinbase/rest/fees.py +++ b/coinbase/rest/fees.py @@ -21,9 +21,7 @@ def get_transaction_summary( "contract_expiry_type": contract_expiry_type, } - # Filter out None values from the params dictionary if kwargs: params.update(kwargs) - params = {key: value for key, value in params.items() if value is not None} return self.get(endpoint, params=params) diff --git a/coinbase/rest/market_data.py b/coinbase/rest/market_data.py index 5eaf5b3..d972300 100644 --- a/coinbase/rest/market_data.py +++ b/coinbase/rest/market_data.py @@ -42,9 +42,7 @@ def get_market_trades( params = {"limit": limit, "start": start, "end": end} - # Filter out None values from the params dictionary if kwargs: params.update(kwargs) - params = {key: value for key, value in params.items() if value is not None} return self.get(endpoint, params=params) diff --git a/coinbase/rest/orders.py b/coinbase/rest/orders.py index b2a0366..4bbf6e6 100644 --- a/coinbase/rest/orders.py +++ b/coinbase/rest/orders.py @@ -59,10 +59,14 @@ def market_order( https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder """ - order_configuration = { - "market_market_ioc": {"quote_size": quote_size, "base_size": base_size} + + 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, @@ -667,10 +671,8 @@ def list_orders( "retail_portfolio_id": retail_portfolio_id, } - # Filter out None values from the params dictionary if kwargs: params.update(kwargs) - params = {key: value for key, value in params.items() if value is not None} return self.get(endpoint, params=params) @@ -700,10 +702,8 @@ def get_fills( "cursor": cursor, } - # Filter out None values from the params dictionary if kwargs: params.update(kwargs) - params = {key: value for key, value in params.items() if value is not None} return self.get(endpoint, params=params) diff --git a/coinbase/rest/portfolios.py b/coinbase/rest/portfolios.py index eef1c9f..ab02669 100644 --- a/coinbase/rest/portfolios.py +++ b/coinbase/rest/portfolios.py @@ -13,10 +13,8 @@ def get_portfolios(self, portfolio_type: Optional[str] = None, **kwargs): params = {"portfolio_type": portfolio_type} - # Filter out None values from the params dictionary if kwargs: params.update(kwargs) - params = {key: value for key, value in params.items() if value is not None} return self.get(endpoint, params=params) diff --git a/coinbase/rest/products.py b/coinbase/rest/products.py index 8ac5a08..44b5d86 100644 --- a/coinbase/rest/products.py +++ b/coinbase/rest/products.py @@ -29,10 +29,8 @@ def get_products( "expiring_contract_status": expiring_contract_status, } - # Filter out None values from the params dictionary if kwargs: params.update(kwargs) - params = {key: value for key, value in params.items() if value is not None} return self.get(endpoint, params=params) @@ -62,10 +60,8 @@ def get_product_book(self, product_id: str, limit: Optional[int] = None, **kwarg params = {"product_id": product_id, "limit": limit} - # Filter out None values from the params dictionary if kwargs: params.update(kwargs) - params = {key: value for key, value in params.items() if value is not None} return self.get(endpoint, params=params) @@ -83,9 +79,7 @@ def get_best_bid_ask(self, product_ids: Optional[List[str]] = None, **kwargs): "product_ids": product_ids, } - # Filter out None values from the params dictionary if kwargs: params.update(kwargs) - params = {key: value for key, value in params.items() if value is not None} return self.get(endpoint, params=params) diff --git a/coinbase/rest/rest_base.py b/coinbase/rest/rest_base.py index 3b79783..7012798 100644 --- a/coinbase/rest/rest_base.py +++ b/coinbase/rest/rest_base.py @@ -10,25 +10,6 @@ from coinbase.constants import API_ENV_KEY, API_SECRET_ENV_KEY, BASE_URL -def prepare_params(params): - if params is None: - return None - - def encode_value(key, value): - if isinstance(value, list): - return "&".join(f"{key}={v}" for v in value) - else: - return f"{key}={value}" - - return "&".join(encode_value(key, value) for key, value in params.items()) - - -def encode(data): - if data is None: - return None - return json.dumps(data).encode("utf-8") - - def handle_exception(response): """Raises :class:`HTTPError`, if one occurred.""" @@ -95,21 +76,27 @@ def prepare_and_send_request( ): headers = self.set_headers(http_method, url_path) - params_string = prepare_params(params) - if params_string: - url_path = f"{url_path}?{params_string}" + 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} - data_encoded = encode(data) - return self.send_request(http_method, url_path, headers, data=data_encoded) + return self.send_request(http_method, url_path, params, headers, data=data) - def send_request(self, http_method, url_path, headers, data=None): + def send_request(self, http_method, url_path, params, headers, data=None): if data is None: data = {} url = f"https://{self.base_url}{url_path}" response = requests.request( - http_method, url, data=data, headers=headers, timeout=self.timeout + http_method, + url, + params=params, + json=data, + headers=headers, + timeout=self.timeout, ) handle_exception(response) # Raise an HTTPError for bad responses diff --git a/setup.py b/setup.py index 74b2c71..cd1820d 100644 --- a/setup.py +++ b/setup.py @@ -5,6 +5,9 @@ with open(os.path.join(os.path.dirname(__file__), "requirements.txt"), "r") as fh: requirements = fh.readlines() +with open(os.path.join(os.path.dirname(__file__), "test_requirements.txt"), "r") as fh: + test_requirements = fh.readlines() + about = {} root = os.path.abspath(os.path.dirname(__file__)) @@ -18,5 +21,8 @@ AUTHOR="Coinbase", packages=find_packages(), install_requires=[req for req in requirements], + extras_require={ + "test": [test_req for test_req in test_requirements], + }, python_requires=">=3.8", ) diff --git a/test_requirements.txt b/test_requirements.txt new file mode 100644 index 0000000..3864a05 --- /dev/null +++ b/test_requirements.txt @@ -0,0 +1 @@ +requests-mock \ 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..5366ef3 --- /dev/null +++ b/tests/rest/test_accounts.py @@ -0,0 +1,44 @@ +import unittest + +from requests_mock import Mocker + +from coinbase.rest import RESTClient +from tests.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..fc4f8ca --- /dev/null +++ b/tests/rest/test_common.py @@ -0,0 +1,26 @@ +import unittest + +from requests_mock import Mocker + +from coinbase.rest import RESTClient +from tests.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..e437d33 --- /dev/null +++ b/tests/rest/test_convert.py @@ -0,0 +1,79 @@ +import unittest + +from requests_mock import Mocker + +from coinbase.rest import RESTClient +from tests.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") + + 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", + }, + ) + 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..7d8e933 --- /dev/null +++ b/tests/rest/test_fees.py @@ -0,0 +1,31 @@ +import unittest + +from requests_mock import Mocker + +from coinbase.rest import RESTClient +from tests.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_market_data.py b/tests/rest/test_market_data.py new file mode 100644 index 0000000..81610db --- /dev/null +++ b/tests/rest/test_market_data.py @@ -0,0 +1,53 @@ +import unittest + +from requests_mock import Mocker + +from coinbase.rest import RESTClient +from tests.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..c6009c9 --- /dev/null +++ b/tests/rest/test_orders.py @@ -0,0 +1,769 @@ +import unittest + +from requests_mock import Mocker + +from coinbase.rest import RESTClient +from tests.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_request.path, "/api/v3/brokerage/orders") + 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) diff --git a/tests/rest/test_portfolios.py b/tests/rest/test_portfolios.py new file mode 100644 index 0000000..b18a934 --- /dev/null +++ b/tests/rest/test_portfolios.py @@ -0,0 +1,129 @@ +import unittest + +from requests_mock import Mocker + +from coinbase.rest import RESTClient +from tests.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": {"amount": "100", "currency": "USD"}, + "source_portfolio_id": "1234", + "target_portfolio_id": "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..91c8138 --- /dev/null +++ b/tests/rest/test_products.py @@ -0,0 +1,82 @@ +import unittest + +from requests_mock import Mocker + +from coinbase.rest import RESTClient +from tests.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..3c98266 --- /dev/null +++ b/tests/rest/test_rest_base.py @@ -0,0 +1,73 @@ +import unittest + +from requests_mock import Mocker + +from coinbase.__version__ import __version__ +from coinbase.rest import RESTClient +from tests.constants import TEST_API_KEY, TEST_API_SECRET + + +class RestBaseTest(unittest.TestCase): + def test_get(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/accounts", + json=expected_response, + ) + + params = {"limit": 2} + accounts = client.get("/api/v3/brokerage/accounts", params) + + 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") + + 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(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, + ) + + data = {"name": "TestName"} + portfolio = client.post("/api/v3/brokerage/portfolios", data=data) + + 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, data) + + 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) diff --git a/tests/test_jwt_generator.py b/tests/test_jwt_generator.py new file mode 100644 index 0000000..3b4f303 --- /dev/null +++ b/tests/test_jwt_generator.py @@ -0,0 +1,42 @@ +import base64 +import json +import unittest + +import jwt + +from coinbase import jwt_generator +from coinbase.constants import REST_SERVICE, WS_SERVICE +from tests.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) From c1854df96111bd5da1b72f0acb9c0c6d80830892 Mon Sep 17 00:00:00 2001 From: urischwartz-cb <143205923+urischwartz-cb@users.noreply.github.com> Date: Thu, 14 Dec 2023 10:44:14 -0500 Subject: [PATCH 04/21] Release v1.0.0 (#5) --- Makefile | 8 ++++++-- README.md | 11 +++++++---- requirements.txt | 10 +++++----- test_requirements.txt | 2 +- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index 4865f3a..aa0c1e6 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,9 @@ -.PHONY: format +.PHONY: format, test format: @echo "Formatting code..." isort . - black . \ No newline at end of file + black . + +test: + @echo "Running tests..." + python3 -m unittest discover -v \ No newline at end of file diff --git a/README.md b/README.md index ded382c..4284a1a 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ pip3 install . This SDK uses the Coinbase Cloud API keys. To use this SDK, you will need to create a Coinbase Cloud API key and secret. You can do this 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}" @@ -31,13 +33,14 @@ If you did not set your API key and secret in your environment, you can pass the ```python from coinbase.rest import RESTClient -client = RESTClient(api_key="", api_secret="") +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) ``` You can also set a timeout in seconds for your REST requests like so: ```python -from coinbase.rest import RESTClient - -client = RESTClient(api_key="", api_secret="", timeout=5) +client = RESTClient(api_key=api_key, api_secret=api_secret, timeout=5) ``` ### Using the Client diff --git a/requirements.txt b/requirements.txt index 47b30cf..9e3cfda 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -requests -black>=23.3.0 -isort>=5.11.5 -cryptography>=41.0.5 -PyJWT>=2.8.0 \ No newline at end of file +requests==2.31.0 +black==23.3.0 +isort==5.12.0 +cryptography==41.0.5 +PyJWT==2.8.0 \ No newline at end of file diff --git a/test_requirements.txt b/test_requirements.txt index 3864a05..b03b03f 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -1 +1 @@ -requests-mock \ No newline at end of file +requests-mock==1.11.0 \ No newline at end of file From 6d3f4335d05aa9d900d4ed01523c5b4d1524d816 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Dec 2023 14:20:56 -0500 Subject: [PATCH 05/21] Bump cryptography from 41.0.5 to 41.0.6 (#6) Bumps [cryptography](https://github.com/pyca/cryptography) from 41.0.5 to 41.0.6. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/41.0.5...41.0.6) --- updated-dependencies: - dependency-name: cryptography dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9e3cfda..0098735 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ requests==2.31.0 black==23.3.0 isort==5.12.0 -cryptography==41.0.5 +cryptography==41.0.6 PyJWT==2.8.0 \ No newline at end of file From 19914e4b5d3b489475f3e8e6869dcad96e0fb446 Mon Sep 17 00:00:00 2001 From: urischwartz-cb <143205923+urischwartz-cb@users.noreply.github.com> Date: Mon, 18 Dec 2023 15:01:19 -0500 Subject: [PATCH 06/21] Release v1.0.0 (#7) --- .gitignore | 11 ++++ CHANGELOG.md | 6 ++ README.md | 20 ++++--- coinbase/rest/accounts.py | 11 +--- coinbase/rest/common.py | 6 +- coinbase/rest/convert.py | 15 +---- coinbase/rest/fees.py | 5 +- coinbase/rest/market_data.py | 10 +--- coinbase/rest/orders.py | 36 +++-------- coinbase/rest/portfolios.py | 32 ++-------- coinbase/rest/products.py | 21 ++----- coinbase/rest/rest_base.py | 42 +++++++++++-- setup.py | 31 +++++++--- tests/rest/test_convert.py | 12 +++- tests/rest/test_rest_base.py | 113 +++++++++++++++++++++++++++++++++-- 15 files changed, 237 insertions(+), 134 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.gitignore b/.gitignore index 48ccc61..7a477af 100644 --- a/.gitignore +++ b/.gitignore @@ -54,4 +54,15 @@ venv.bak/ .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..c2ec449 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +## [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/README.md b/README.md index 4284a1a..759d503 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,14 @@ # Coinbase Advanced Trading API Python SDK +[![PyPI version](https://badge.fury.io/py/coinbase-advanced.svg)](https://badge.fury.io/py/coinbase-advanced) +[![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 Trading API Python SDK. This python project was created to allow coders to easily plug into the [Coinbase Advanced Trade API](https://docs.cloud.coinbase.com/advanced-trade-api/docs/welcome). ## Installation -To install, please clone this git repo, cd into the root and run: ```bash -pip3 install . +pip3 install coinbase-advanced ``` ## Cloud API Keys @@ -52,14 +54,13 @@ from json import dumps accounts = client.get_accounts() print(dumps(accounts, indent=2)) -order = client.market_order_buy("clientOrderId", "BTC-USD", "1") +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. You can refer to the [Advanced Trade API Reference](https://docs.cloud.coinbase.com/advanced-trade-api/reference) for detailed information on each exposed endpoint. -You can look at the following [mapping](Todo--add link here) to see which API hook corresponds to which endpoint. - +You can look in the `coinbase.rest` module to see the API hooks that are exposed. ### Passing in additional parameters You can use `kwargs` to pass in any additional parameters. For example: @@ -79,11 +80,11 @@ market_trades = client.get("/api/v3/brokerage/products/BTC-USD/ticker", params={ 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: +Once again, the built-in way to query these through the SDK would be: ```python -market_trades = client.get_market_trades("BTC-USD", 5) +market_trades = client.get_market_trades(product_id="BTC-USD", limit=5) -portfolio = client.create_portfolio("TestPortfolio") +portfolio = client.create_portfolio(name="TestPortfolio") ``` ## Authentication @@ -117,6 +118,9 @@ jwt = jwt_generator.build_ws_jwt(api_key, api_secret) ``` You can 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](CHANGELOG.md). + ## Contributing If you've found a bug within this project, please open an issue on this repo and add the "bug" label to it. diff --git a/coinbase/rest/accounts.py b/coinbase/rest/accounts.py index b1353f0..ce4a88a 100644 --- a/coinbase/rest/accounts.py +++ b/coinbase/rest/accounts.py @@ -14,10 +14,7 @@ def get_accounts( endpoint = f"{API_PREFIX}/accounts" params = {"limit": limit, "cursor": cursor} - if kwargs: - params.update(kwargs) - - return self.get(endpoint, params=params) + return self.get(endpoint, params=params, **kwargs) def get_account(self, account_uuid: str, **kwargs): @@ -28,8 +25,4 @@ def get_account(self, account_uuid: str, **kwargs): """ endpoint = f"{API_PREFIX}/accounts/{account_uuid}" - params = {} - if kwargs: - params.update(kwargs) - - return self.get(endpoint, params=params) + return self.get(endpoint, **kwargs) diff --git a/coinbase/rest/common.py b/coinbase/rest/common.py index 760cffc..52a956b 100644 --- a/coinbase/rest/common.py +++ b/coinbase/rest/common.py @@ -9,8 +9,4 @@ def get_unix_time(self, **kwargs): """ endpoint = f"{API_PREFIX}/time" - params = {} - if kwargs: - params.update(kwargs) - - return self.get(endpoint, params=params) + return self.get(endpoint, **kwargs) diff --git a/coinbase/rest/convert.py b/coinbase/rest/convert.py index 909cc34..5fb0bfe 100644 --- a/coinbase/rest/convert.py +++ b/coinbase/rest/convert.py @@ -38,10 +38,7 @@ def create_convert_quote( if filtered_trade_incentive_metadata: data["trade_incentive_metadata"] = filtered_trade_incentive_metadata - if kwargs: - data.update(kwargs) - - return self.post(endpoint, data=data) + return self.post(endpoint, data=data, **kwargs) def get_convert_trade( @@ -59,10 +56,7 @@ def get_convert_trade( "to_account": to_account, } - if kwargs: - params.update(kwargs) - - return self.get(endpoint, params=params) + return self.get(endpoint, params=params, **kwargs) def commit_convert_trade( @@ -80,7 +74,4 @@ def commit_convert_trade( "to_account": to_account, } - if kwargs: - data.update(kwargs) - - return self.post(endpoint, data=data) + return self.post(endpoint, data=data, **kwargs) diff --git a/coinbase/rest/fees.py b/coinbase/rest/fees.py index d561212..05861be 100644 --- a/coinbase/rest/fees.py +++ b/coinbase/rest/fees.py @@ -21,7 +21,4 @@ def get_transaction_summary( "contract_expiry_type": contract_expiry_type, } - if kwargs: - params.update(kwargs) - - return self.get(endpoint, params=params) + return self.get(endpoint, params=params, **kwargs) diff --git a/coinbase/rest/market_data.py b/coinbase/rest/market_data.py index d972300..2ae5ff7 100644 --- a/coinbase/rest/market_data.py +++ b/coinbase/rest/market_data.py @@ -19,10 +19,7 @@ def get_candles( "granularity": granularity, } - if kwargs: - params.update(kwargs) - - return self.get(endpoint, params=params) + return self.get(endpoint, params=params, **kwargs) def get_market_trades( @@ -42,7 +39,4 @@ def get_market_trades( params = {"limit": limit, "start": start, "end": end} - if kwargs: - params.update(kwargs) - - return self.get(endpoint, params=params) + return self.get(endpoint, params=params, **kwargs) diff --git a/coinbase/rest/orders.py b/coinbase/rest/orders.py index 4bbf6e6..aea8513 100644 --- a/coinbase/rest/orders.py +++ b/coinbase/rest/orders.py @@ -33,10 +33,7 @@ def create_order( "retail_portfolio_id": retail_portfolio_id, } - if kwargs: - data.update(kwargs) - - return self.post(endpoint, data=data) + return self.post(endpoint, data=data, **kwargs) # Market orders @@ -625,11 +622,7 @@ def get_order(self, order_id: str, **kwargs): """ endpoint = f"{API_PREFIX}/orders/historical/{order_id}" - params = {} - if kwargs: - params.update(kwargs) - - return self.get(endpoint, params=params) + return self.get(endpoint, **kwargs) def list_orders( @@ -671,10 +664,7 @@ def list_orders( "retail_portfolio_id": retail_portfolio_id, } - if kwargs: - params.update(kwargs) - - return self.get(endpoint, params=params) + return self.get(endpoint, params=params, **kwargs) def get_fills( @@ -702,10 +692,7 @@ def get_fills( "cursor": cursor, } - if kwargs: - params.update(kwargs) - - return self.get(endpoint, params=params) + return self.get(endpoint, params=params, **kwargs) def edit_order( @@ -728,10 +715,7 @@ def edit_order( "price": price, } - if kwargs: - data.update(kwargs) - - return self.post(endpoint, data=data) + return self.post(endpoint, data=data, **kwargs) def preview_edit_order( @@ -754,10 +738,7 @@ def preview_edit_order( "price": price, } - if kwargs: - data.update(kwargs) - - return self.post(endpoint, data=data) + return self.post(endpoint, data=data, **kwargs) def cancel_orders(self, order_ids: List[str], **kwargs): @@ -771,7 +752,4 @@ def cancel_orders(self, order_ids: List[str], **kwargs): "order_ids": order_ids, } - if kwargs: - data.update(kwargs) - - return self.post(endpoint, data=data) + return self.post(endpoint, data=data, **kwargs) diff --git a/coinbase/rest/portfolios.py b/coinbase/rest/portfolios.py index ab02669..c75925d 100644 --- a/coinbase/rest/portfolios.py +++ b/coinbase/rest/portfolios.py @@ -13,10 +13,7 @@ def get_portfolios(self, portfolio_type: Optional[str] = None, **kwargs): params = {"portfolio_type": portfolio_type} - if kwargs: - params.update(kwargs) - - return self.get(endpoint, params=params) + return self.get(endpoint, params=params, **kwargs) def create_portfolio(self, name: str, **kwargs): @@ -31,10 +28,7 @@ def create_portfolio(self, name: str, **kwargs): "name": name, } - if kwargs: - data.update(kwargs) - - return self.post(endpoint, data=data) + return self.post(endpoint, data=data, **kwargs) def get_portfolio_breakdown(self, portfolio_uuid: str, **kwargs): @@ -45,11 +39,7 @@ def get_portfolio_breakdown(self, portfolio_uuid: str, **kwargs): """ endpoint = f"{API_PREFIX}/portfolios/{portfolio_uuid}" - params = {} - if kwargs: - params.update(kwargs) - - return self.get(endpoint, params=params) + return self.get(endpoint, **kwargs) def move_portfolio_funds( @@ -76,10 +66,7 @@ def move_portfolio_funds( "target_portfolio_id": target_portfolio_uuid, } - if kwargs: - data.update(kwargs) - - return self.post(endpoint, data=data) + return self.post(endpoint, data=data, **kwargs) def edit_portfolio(self, portfolio_uuid: str, name: str, **kwargs): @@ -94,10 +81,7 @@ def edit_portfolio(self, portfolio_uuid: str, name: str, **kwargs): "name": name, } - if kwargs: - data.update(kwargs) - - return self.put(endpoint, data=data) + return self.put(endpoint, data=data, **kwargs) def delete_portfolio(self, portfolio_uuid: str, **kwargs): @@ -108,8 +92,4 @@ def delete_portfolio(self, portfolio_uuid: str, **kwargs): """ endpoint = f"{API_PREFIX}/portfolios/{portfolio_uuid}" - data = {} - if kwargs: - data.update(kwargs) - - return self.delete(endpoint, data=data) + return self.delete(endpoint, **kwargs) diff --git a/coinbase/rest/products.py b/coinbase/rest/products.py index 44b5d86..695ebfd 100644 --- a/coinbase/rest/products.py +++ b/coinbase/rest/products.py @@ -29,10 +29,7 @@ def get_products( "expiring_contract_status": expiring_contract_status, } - if kwargs: - params.update(kwargs) - - return self.get(endpoint, params=params) + return self.get(endpoint, params=params, **kwargs) def get_product(self, product_id: str, **kwargs): @@ -43,11 +40,7 @@ def get_product(self, product_id: str, **kwargs): """ endpoint = f"{API_PREFIX}/products/{product_id}" - params = {} - if kwargs: - params.update(kwargs) - - return self.get(endpoint, params=params) + return self.get(endpoint, **kwargs) def get_product_book(self, product_id: str, limit: Optional[int] = None, **kwargs): @@ -60,10 +53,7 @@ def get_product_book(self, product_id: str, limit: Optional[int] = None, **kwarg params = {"product_id": product_id, "limit": limit} - if kwargs: - params.update(kwargs) - - return self.get(endpoint, params=params) + return self.get(endpoint, params=params, **kwargs) def get_best_bid_ask(self, product_ids: Optional[List[str]] = None, **kwargs): @@ -79,7 +69,4 @@ def get_best_bid_ask(self, product_ids: Optional[List[str]] = None, **kwargs): "product_ids": product_ids, } - if kwargs: - params.update(kwargs) - - return self.get(endpoint, params=params) + return self.get(endpoint, params=params, **kwargs) diff --git a/coinbase/rest/rest_base.py b/coinbase/rest/rest_base.py index 7012798..0947bae 100644 --- a/coinbase/rest/rest_base.py +++ b/coinbase/rest/rest_base.py @@ -51,20 +51,54 @@ def __init__( self.base_url = base_url self.timeout = timeout - def get(self, url_path, params: Optional[dict] = None): + def get(self, url_path, params: Optional[dict] = None, **kwargs): + 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 + self, + url_path, + params: Optional[dict] = None, + data: Optional[dict] = None, + **kwargs, ): + 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): + def put( + self, + url_path, + params: Optional[dict] = None, + data: Optional[dict] = None, + **kwargs, + ): + 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 + self, + url_path, + params: Optional[dict] = None, + data: Optional[dict] = None, + **kwargs, ): + data = data or {} + + if kwargs: + data.update(kwargs) + return self.prepare_and_send_request("DELETE", url_path, params, data) def prepare_and_send_request( diff --git a/setup.py b/setup.py index cd1820d..97a1563 100644 --- a/setup.py +++ b/setup.py @@ -2,27 +2,44 @@ from setuptools import find_packages, setup -with open(os.path.join(os.path.dirname(__file__), "requirements.txt"), "r") as fh: +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(os.path.dirname(__file__), "test_requirements.txt"), "r") as fh: +with open(os.path.join(root, "test_requirements.txt"), "r") as fh: test_requirements = fh.readlines() -about = {} +README = open(os.path.join(root, "README.md"), "r").read() -root = os.path.abspath(os.path.dirname(__file__)) +about = {} with open(os.path.join(root, "coinbase", "__version__.py")) as f: exec(f.read(), about) setup( - name="coinbase-advanced-py", + name="coinbase-advanced", version=about["__version__"], - AUTHOR="Coinbase", - packages=find_packages(), + license="Apache 2.0", + description="Coinbase Advanced Trade 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"], + packages=find_packages(exclude=("tests",)), install_requires=[req for req in requirements], extras_require={ "test": [test_req for test_req in test_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/tests/rest/test_convert.py b/tests/rest/test_convert.py index e437d33..7a8ae4a 100644 --- a/tests/rest/test_convert.py +++ b/tests/rest/test_convert.py @@ -18,7 +18,13 @@ def test_create_convert_quote(self): "https://api.coinbase.com/api/v3/brokerage/convert/quote", json=expected_response, ) - quote = client.create_convert_quote("from_account", "to_account", "100") + 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() @@ -30,6 +36,10 @@ def test_create_convert_quote(self): "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) diff --git a/tests/rest/test_rest_base.py b/tests/rest/test_rest_base.py index 3c98266..2fea0ca 100644 --- a/tests/rest/test_rest_base.py +++ b/tests/rest/test_rest_base.py @@ -1,5 +1,6 @@ import unittest +from requests.exceptions import HTTPError from requests_mock import Mocker from coinbase.__version__ import __version__ @@ -8,6 +9,13 @@ class RestBaseTest(unittest.TestCase): + def test_no_api_key(self): + with self.assertRaises(Exception): + RESTClient(None, None) + + with self.assertRaises(Exception): + RESTClient("test_key", None) + def test_get(self): client = RESTClient(TEST_API_KEY, TEST_API_SECRET) @@ -21,7 +29,8 @@ def test_get(self): ) params = {"limit": 2} - accounts = client.get("/api/v3/brokerage/accounts", params) + kwargs = {"test_kwarg": "test"} + accounts = client.get("/api/v3/brokerage/accounts", params, **kwargs) captured_request = m.request_history[0] captured_query = captured_request.query @@ -29,7 +38,7 @@ def test_get(self): self.assertEqual(captured_request.method, "GET") - self.assertEqual(captured_query, "limit=2") + self.assertEqual(captured_query, "limit=2&test_kwarg=test") self.assertTrue("User-Agent" in captured_headers) self.assertEqual( @@ -53,7 +62,9 @@ def test_post(self): ) data = {"name": "TestName"} - portfolio = client.post("/api/v3/brokerage/portfolios", data=data) + 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() @@ -61,7 +72,75 @@ def test_post(self): self.assertEqual(captured_request.method, "POST") - self.assertEqual(captured_json, data) + 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(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, + ) + + 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(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, + ) + + 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( @@ -71,3 +150,29 @@ def test_post(self): self.assertTrue(captured_headers["Authorization"].startswith("Bearer ")) self.assertEqual(portfolio, expected_response) + + def test_client_error(self): + client = RESTClient(TEST_API_KEY, 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(TEST_API_KEY, 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") From c5f110bbb38ab2ecd68de5cd55c2cc424937c383 Mon Sep 17 00:00:00 2001 From: urischwartz-cb <143205923+urischwartz-cb@users.noreply.github.com> Date: Tue, 19 Dec 2023 12:12:03 -0500 Subject: [PATCH 07/21] Release v1.0.0 (#8) --- README.md | 26 +++++++++++++------------- setup.py | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 759d503..3a11b62 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![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 Trading API Python SDK. This python project was created to allow coders to easily plug into the [Coinbase Advanced Trade API](https://docs.cloud.coinbase.com/advanced-trade-api/docs/welcome). +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). ## Installation @@ -13,7 +13,7 @@ pip3 install coinbase-advanced ## 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. You can do this by following the instructions [here](https://docs.cloud.coinbase.com/advanced-trade-api/docs/auth#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. @@ -47,7 +47,7 @@ client = RESTClient(api_key=api_key, api_secret=api_secret, timeout=5) ### Using the Client -You can then use any of the API hooks to make calls to the Coinbase API. For example: +You are able to use any of the API hooks to make calls to the Coinbase API. For example: ```python from json import dumps @@ -59,11 +59,11 @@ print(dumps(order, indent=2)) ``` This code calls the `get_accounts` and `market_order_buy` endpoints. -You can refer to the [Advanced Trade API Reference](https://docs.cloud.coinbase.com/advanced-trade-api/reference) for detailed information on each exposed endpoint. -You can look in the `coinbase.rest` module to see the API hooks that are exposed. +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 -You can use `kwargs` to pass in any additional parameters. For example: +Use `kwargs` to pass in any additional parameters. For example: ```python kwargs = { "param1": 10, @@ -73,7 +73,7 @@ product = client.get_product(product_id="BTC-USD", **kwargs) ``` ### Generic REST Calls -You can also make generic REST calls using the `get`, `post`, `put`, and `delete` methods. For example: +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}) @@ -90,7 +90,7 @@ portfolio = client.create_portfolio(name="TestPortfolio") ## Authentication Authentication of Cloud API Keys is handled automatically by the SDK when making a REST request. -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). You can use the built in `jwt_generator` to create your JWT token. For example: +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 @@ -102,7 +102,7 @@ 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. You can then pass this JWT token in the `Authorization` header of your request as: +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 ` @@ -116,13 +116,13 @@ api_secret = "-----BEGIN EC PRIVATE KEY-----\nYOUR PRIVATE KEY\n-----END EC PRIV jwt = jwt_generator.build_ws_jwt(api_key, api_secret) ``` -You can 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. +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](CHANGELOG.md). ## Contributing -If you've found a bug within this project, please open an issue on this repo and add the "bug" label to it. -If you would like to request a new feature, please open an issue on this repo and add the "enhancement" label to it. -Please direct concerns or questions on the API to the [Advanced Trade API Developer Forum](https://forums.coinbasecloud.dev/c/advanced-trade-api/20). +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/setup.py b/setup.py index 97a1563..4e30a65 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ long_description_content_type="text/markdown", author="Coinbase", url="https://github.com/coinbase/coinbase-advanced-py", - keywords=["Coinbase", "Advanced Trade", "API"], + keywords=["Coinbase", "Advanced Trade", "API", "Advanced API"], packages=find_packages(exclude=("tests",)), install_requires=[req for req in requirements], extras_require={ From 8748f26600c80940b8840f325ea1b8dfdec536a3 Mon Sep 17 00:00:00 2001 From: urischwartz-cb <143205923+urischwartz-cb@users.noreply.github.com> Date: Wed, 3 Jan 2024 16:32:35 -0500 Subject: [PATCH 08/21] Release v1.0.0 (#10) --- coinbase/jwt_generator.py | 3 +- coinbase/rest/__init__.py | 8 +++ coinbase/rest/futures.py | 69 ++++++++++++++++++++++ tests/rest/test_futures.py | 118 +++++++++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 coinbase/rest/futures.py create mode 100644 tests/rest/test_futures.py diff --git a/coinbase/jwt_generator.py b/coinbase/jwt_generator.py index 67fb9c4..ff98ef2 100644 --- a/coinbase/jwt_generator.py +++ b/coinbase/jwt_generator.py @@ -1,3 +1,4 @@ +import secrets import time import jwt @@ -25,7 +26,7 @@ def build_jwt(key_var, secret_var, service, uri=None): jwt_data, private_key, algorithm="ES256", - headers={"kid": key_var, "nonce": str(int(time.time()))}, + headers={"kid": key_var, "nonce": secrets.token_hex()}, ) return jwt_token diff --git a/coinbase/rest/__init__.py b/coinbase/rest/__init__.py index 8fb39ff..842ad9e 100755 --- a/coinbase/rest/__init__.py +++ b/coinbase/rest/__init__.py @@ -6,6 +6,14 @@ class RESTClient(RESTBase): 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, diff --git a/coinbase/rest/futures.py b/coinbase/rest/futures.py new file mode 100644 index 0000000..926e417 --- /dev/null +++ b/coinbase/rest/futures.py @@ -0,0 +1,69 @@ +from coinbase.constants import API_PREFIX + + +def get_futures_balance_summary(self, **kwargs): + """ + Get information on your balances related to Coinbase Financial Markets (CFM) futures trading. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getfcmbalancesummary + """ + endpoint = f"{API_PREFIX}/cfm/balance_summary" + + return self.get(endpoint, **kwargs) + + +def list_futures_positions(self, **kwargs): + """ + Get a list of all open positions in CFM futures products. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getfcmpositions + """ + endpoint = f"{API_PREFIX}/cfm/positions" + + return self.get(endpoint, **kwargs) + + +def get_futures_position(self, product_id: str, **kwargs): + """ + Get the position of a specific CFM futures product. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getfcmposition + """ + endpoint = f"{API_PREFIX}/cfm/positions/{product_id}" + + return self.get(endpoint, **kwargs) + + +def schedule_futures_sweep(self, usd_amount: str, **kwargs): + """ + Schedule a sweep of funds from your CFTC-regulated futures account to your Coinbase Inc. USD Spot wallet. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_schedulefcmsweep + """ + 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): + """ + 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. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getfcmsweeps + """ + endpoint = f"{API_PREFIX}/cfm/sweeps" + + return self.get(endpoint, **kwargs) + + +def cancel_pending_futures_sweep(self, **kwargs): + """ + Cancel your pending sweep of funds from your CFTC-regulated futures account to your Coinbase Inc. USD Spot wallet. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_cancelfcmsweep + """ + endpoint = f"{API_PREFIX}/cfm/sweeps" + + return self.delete(endpoint, **kwargs) diff --git a/tests/rest/test_futures.py b/tests/rest/test_futures.py new file mode 100644 index 0000000..f1a0fa7 --- /dev/null +++ b/tests/rest/test_futures.py @@ -0,0 +1,118 @@ +import unittest + +from requests_mock import Mocker + +from coinbase.rest import RESTClient +from tests.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) From fbf5695159d8748a2510fdf2382419810e95a339 Mon Sep 17 00:00:00 2001 From: urischwartz-cb <143205923+urischwartz-cb@users.noreply.github.com> Date: Wed, 3 Jan 2024 22:31:24 -0500 Subject: [PATCH 09/21] v1.0.1 (#11) --- CHANGELOG.md | 5 +++++ README.md | 4 ++-- coinbase/__version__.py | 2 +- setup.py | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2ec449..5a3ad95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [1.0.0] - 2024-JAN-3 + +### Added +- Support for Futures API endpoints + ## [1.0.0] - 2023-DEC-18 ### Added diff --git a/README.md b/README.md index 3a11b62..0e9136a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Coinbase Advanced Trading API Python SDK -[![PyPI version](https://badge.fury.io/py/coinbase-advanced.svg)](https://badge.fury.io/py/coinbase-advanced) +[![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/) @@ -8,7 +8,7 @@ Welcome to the official Coinbase Advanced API Python SDK. This python project wa ## Installation ```bash -pip3 install coinbase-advanced +pip3 install coinbase-advanced-py ``` ## Cloud API Keys diff --git a/coinbase/__version__.py b/coinbase/__version__.py index 5becc17..5c4105c 100644 --- a/coinbase/__version__.py +++ b/coinbase/__version__.py @@ -1 +1 @@ -__version__ = "1.0.0" +__version__ = "1.0.1" diff --git a/setup.py b/setup.py index 4e30a65..9d70dc5 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ exec(f.read(), about) setup( - name="coinbase-advanced", + name="coinbase-advanced-py", version=about["__version__"], license="Apache 2.0", description="Coinbase Advanced Trade API Python SDK", From 279f94cd54992af031a1591a9dcfb4f23b3dc486 Mon Sep 17 00:00:00 2001 From: urischwartz-cb <143205923+urischwartz-cb@users.noreply.github.com> Date: Fri, 5 Jan 2024 10:57:07 -0500 Subject: [PATCH 10/21] Update CHANGELOG.md (#12) --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a3ad95..01db411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [1.0.0] - 2024-JAN-3 +## [1.0.1] - 2024-JAN-3 ### Added - Support for Futures API endpoints @@ -8,4 +8,4 @@ ## [1.0.0] - 2023-DEC-18 ### Added -- Initial release of the Coinbase Advanced Trading API Python SDK \ No newline at end of file +- Initial release of the Coinbase Advanced Trading API Python SDK From 10c745962559e7afe9ea38768a95607eb9faf80d Mon Sep 17 00:00:00 2001 From: urischwartz-cb <143205923+urischwartz-cb@users.noreply.github.com> Date: Tue, 9 Jan 2024 16:06:17 +0000 Subject: [PATCH 11/21] Add Github Actions for Issues on repo (#13) --- .github/workflows/issues.yml | 17 +++++++++ .github/workflows/notification.yml | 61 ++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 .github/workflows/issues.yml create mode 100644 .github/workflows/notification.yml diff --git a/.github/workflows/issues.yml b/.github/workflows/issues.yml new file mode 100644 index 0000000..fe5f4e8 --- /dev/null +++ b/.github/workflows/issues.yml @@ -0,0 +1,17 @@ +on: + issues: + types: [opened] + +jobs: + comment: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: 'Thank you for reporting! If this is an SDK specific issue, we will look into it and get back to you soon. If this is an API related request, report it in our [Advanced API forum](https://forums.coinbasecloud.dev/c/advanced-trade-api/20) instead.' + }) \ No newline at end of file diff --git a/.github/workflows/notification.yml b/.github/workflows/notification.yml new file mode 100644 index 0000000..f4e6845 --- /dev/null +++ b/.github/workflows/notification.yml @@ -0,0 +1,61 @@ +name: Slack notifications for PRs and Issues + +on: + issues: + types: [opened, reopened] + +env: + CHANNEL_WALLET_FEEDBACK: ${{ vars.CHANNEL_API_FEEDBACK }} + ON_CALL_API: ${{ vars.ON_CALL_API }} + +jobs: + checks: + runs-on: ubuntu-latest + outputs: + skip: ${{ env.skip }} + steps: + - name: Check spam labels + if: ${{ contains(github.event.*.labels.*.name, 'spam') }} + run: | + echo "skip=true" >> $GITHUB_ENV + echo "::error:: Spam label found." + + notify: + runs-on: ubuntu-latest + needs: checks + if: ${{ needs.checks.outputs.skip != 'true' }} + steps: + - name: Set channel and mention + run: | + echo "channel=${{ env.CHANNEL_API_FEEDBACK }}" >> $GITHUB_ENV + echo "mention=${{ env.ON_CALL_API }}" >> $GITHUB_ENV + + - name: Set text + run: | + text=$(echo "${{ vars.SLACK_TEMPLATE }}") + text=${text//'{{event}}'/Issue ${{ env.action }}} + text=${text//'{{author}}'/${{ env.author }}} + text=${text//'{{url}}'/${{ env.url }}} + text=${text//'{{mention}}'/${{ env.mention }}} + text=${text//'{{repo}}'/${{ github.repository }}} + text="${text//$'\r\n'/'\n'}" + text="${text//$'\n'/'\n'}" + echo "text=$text" >> $GITHUB_ENV + env: + action: ${{ github.event.action }} + author: ${{ github.event.issue.user.login }} + url: ${{ github.event.issue.html_url }} + + - name: Notify Slack + uses: slackapi/slack-github-action@v1.24.0 + with: + payload: | + { + "channel": "#${{ env.channel }}", + "username": "${{ vars.WEBHOOK_USERNAME }}", + "text": "*${{ env.title }}*\n${{ env.text }}", + "icon_emoji": ":${{ vars.ICON_EMOJI }}:" + } + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + title: ${{ github.event.issue.title }} \ No newline at end of file From 7bd386ec24d646ca7b15ea77ec8f5210648ba604 Mon Sep 17 00:00:00 2001 From: urischwartz-cb <143205923+urischwartz-cb@users.noreply.github.com> Date: Tue, 9 Jan 2024 16:16:56 +0000 Subject: [PATCH 12/21] Fix GitHub issues workflows (#15) * Add Github Actions for Issues on repo * fix github action for issues --- .github/workflows/notification.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/notification.yml b/.github/workflows/notification.yml index f4e6845..f3ac552 100644 --- a/.github/workflows/notification.yml +++ b/.github/workflows/notification.yml @@ -5,7 +5,7 @@ on: types: [opened, reopened] env: - CHANNEL_WALLET_FEEDBACK: ${{ vars.CHANNEL_API_FEEDBACK }} + CHANNEL_API_FEEDBACK: ${{ vars.CHANNEL_API_FEEDBACK }} ON_CALL_API: ${{ vars.ON_CALL_API }} jobs: From 8e572798c6be201a48c2d5bcb5b6bae7c11c6dc6 Mon Sep 17 00:00:00 2001 From: urischwartz-cb <143205923+urischwartz-cb@users.noreply.github.com> Date: Wed, 10 Jan 2024 20:41:06 +0000 Subject: [PATCH 13/21] Release v1.0.2 (#17) --- CHANGELOG.md | 6 ++++++ README.md | 11 ++++++++++- coinbase/__version__.py | 2 +- coinbase/jwt_generator.py | 13 +++++++++++-- coinbase/rest/rest_base.py | 31 +++++++++++++++++++++++++----- setup.py | 2 +- tests/rest/test_rest_base.py | 37 ++++++++++++++++++++++++++++++++++++ tests/test_api_key.json | 13 +++++++++++++ tests/test_jwt_generator.py | 5 +++++ 9 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 tests/test_api_key.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 01db411..571870a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [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 diff --git a/README.md b/README.md index 0e9136a..f069fc5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Coinbase Advanced Trading API Python SDK +# 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/) @@ -40,6 +40,15 @@ api_secret = "-----BEGIN EC PRIVATE KEY-----\nYOUR PRIVATE KEY\n-----END EC PRIV 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) diff --git a/coinbase/__version__.py b/coinbase/__version__.py index 5c4105c..7863915 100644 --- a/coinbase/__version__.py +++ b/coinbase/__version__.py @@ -1 +1 @@ -__version__ = "1.0.1" +__version__ = "1.0.2" diff --git a/coinbase/jwt_generator.py b/coinbase/jwt_generator.py index ff98ef2..4b734b8 100644 --- a/coinbase/jwt_generator.py +++ b/coinbase/jwt_generator.py @@ -8,8 +8,17 @@ def build_jwt(key_var, secret_var, service, uri=None): - private_key_bytes = secret_var.encode("utf-8") - private_key = serialization.load_pem_private_key(private_key_bytes, password=None) + 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, diff --git a/coinbase/rest/rest_base.py b/coinbase/rest/rest_base.py index 0947bae..4addbd5 100644 --- a/coinbase/rest/rest_base.py +++ b/coinbase/rest/rest_base.py @@ -1,6 +1,6 @@ import json import os -from typing import Optional +from typing import IO, Optional, Union import requests from requests.exceptions import HTTPError @@ -17,10 +17,15 @@ def handle_exception(response): reason = response.reason if 400 <= response.status_code < 500: - http_error_msg = ( - f"{response.status_code} Client Error: {reason} {response.text}" - ) - + 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}" @@ -35,9 +40,25 @@ 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=None, ): + 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" diff --git a/setup.py b/setup.py index 9d70dc5..77cc0e2 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ name="coinbase-advanced-py", version=about["__version__"], license="Apache 2.0", - description="Coinbase Advanced Trade API Python SDK", + description="Coinbase Advanced API Python SDK", long_description=README, long_description_content_type="text/markdown", author="Coinbase", diff --git a/tests/rest/test_rest_base.py b/tests/rest/test_rest_base.py index 2fea0ca..541d72a 100644 --- a/tests/rest/test_rest_base.py +++ b/tests/rest/test_rest_base.py @@ -1,4 +1,5 @@ import unittest +from io import StringIO from requests.exceptions import HTTPError from requests_mock import Mocker @@ -176,3 +177,39 @@ def test_server_error(self): with self.assertRaises(HTTPError): client.get("/api/v3/brokerage/accounts") + + def test_key_file_string(self): + try: + RESTClient(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"}' + ) + RESTClient(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"}') + RESTClient(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"}') + RESTClient( + 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"' + ) + RESTClient(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 index 3b4f303..2c9c126 100644 --- a/tests/test_jwt_generator.py +++ b/tests/test_jwt_generator.py @@ -40,3 +40,8 @@ def test_build_ws_jwt(self): 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") From 681ab1701eb6a41fc1d8694d90f2e22c281b987f Mon Sep 17 00:00:00 2001 From: urischwartz-cb <143205923+urischwartz-cb@users.noreply.github.com> Date: Fri, 19 Jan 2024 12:33:48 -0500 Subject: [PATCH 14/21] Release v1.0.3 (#18) --- CHANGELOG.md | 5 ++++ coinbase/__version__.py | 2 +- coinbase/api_base.py | 43 +++++++++++++++++++++++++++ coinbase/jwt_generator.py | 2 +- coinbase/rest/rest_base.py | 37 ++++++------------------ tests/rest/test_rest_base.py | 56 ++++-------------------------------- tests/test_api_base.py | 56 ++++++++++++++++++++++++++++++++++++ 7 files changed, 121 insertions(+), 80 deletions(-) create mode 100644 coinbase/api_base.py create mode 100644 tests/test_api_base.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 571870a..f7dce74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [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 diff --git a/coinbase/__version__.py b/coinbase/__version__.py index 7863915..976498a 100644 --- a/coinbase/__version__.py +++ b/coinbase/__version__.py @@ -1 +1 @@ -__version__ = "1.0.2" +__version__ = "1.0.3" diff --git a/coinbase/api_base.py b/coinbase/api_base.py new file mode 100644 index 0000000..4bb29a5 --- /dev/null +++ b/coinbase/api_base.py @@ -0,0 +1,43 @@ +import json +import os +from typing import IO, Optional, Union + +from coinbase.constants import API_ENV_KEY, API_SECRET_ENV_KEY + + +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=None, + ): + 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/jwt_generator.py b/coinbase/jwt_generator.py index 4b734b8..274e0ab 100644 --- a/coinbase/jwt_generator.py +++ b/coinbase/jwt_generator.py @@ -24,7 +24,7 @@ def build_jwt(key_var, secret_var, service, uri=None): "sub": key_var, "iss": "coinbase-cloud", "nbf": int(time.time()), - "exp": int(time.time()) + 60, + "exp": int(time.time()) + 120, "aud": [service], } diff --git a/coinbase/rest/rest_base.py b/coinbase/rest/rest_base.py index 4addbd5..9d3b6b1 100644 --- a/coinbase/rest/rest_base.py +++ b/coinbase/rest/rest_base.py @@ -7,6 +7,7 @@ from coinbase import jwt_generator from coinbase.__version__ import __version__ +from coinbase.api_base import APIBase from coinbase.constants import API_ENV_KEY, API_SECRET_ENV_KEY, BASE_URL @@ -35,7 +36,7 @@ def handle_exception(response): raise HTTPError(http_error_msg, response=response) -class RESTBase(object): +class RESTBase(APIBase): def __init__( self, api_key: Optional[str] = os.getenv(API_ENV_KEY), @@ -44,33 +45,13 @@ def __init__( base_url=BASE_URL, timeout=None, ): - 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 + super().__init__( + api_key=api_key, + api_secret=api_secret, + key_file=key_file, + base_url=base_url, + timeout=timeout, + ) def get(self, url_path, params: Optional[dict] = None, **kwargs): params = params or {} diff --git a/tests/rest/test_rest_base.py b/tests/rest/test_rest_base.py index 541d72a..135a0d3 100644 --- a/tests/rest/test_rest_base.py +++ b/tests/rest/test_rest_base.py @@ -1,5 +1,4 @@ import unittest -from io import StringIO from requests.exceptions import HTTPError from requests_mock import Mocker @@ -10,15 +9,8 @@ class RestBaseTest(unittest.TestCase): - def test_no_api_key(self): - with self.assertRaises(Exception): - RESTClient(None, None) - - with self.assertRaises(Exception): - RESTClient("test_key", None) - def test_get(self): - client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + client = RESTClient(api_key=TEST_API_KEY, api_secret=TEST_API_SECRET) expected_response = {"key_1": "value_1", "key_2": "value_2"} @@ -51,7 +43,7 @@ def test_get(self): self.assertEqual(accounts, expected_response) def test_post(self): - client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + client = RESTClient(api_key=TEST_API_KEY, api_secret=TEST_API_SECRET) expected_response = {"key_1": "value_1", "key_2": "value_2"} @@ -85,7 +77,7 @@ def test_post(self): self.assertEqual(portfolio, expected_response) def test_put(self): - client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + client = RESTClient(api_key=TEST_API_KEY, api_secret=TEST_API_SECRET) expected_response = {"key_1": "value_1", "key_2": "value_2"} @@ -121,7 +113,7 @@ def test_put(self): self.assertEqual(portfolio, expected_response) def test_delete(self): - client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + client = RESTClient(api_key=TEST_API_KEY, api_secret=TEST_API_SECRET) expected_response = {"key_1": "value_1", "key_2": "value_2"} @@ -153,7 +145,7 @@ def test_delete(self): self.assertEqual(portfolio, expected_response) def test_client_error(self): - client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + client = RESTClient(api_key=TEST_API_KEY, api_secret=TEST_API_SECRET) with Mocker() as m: m.request( @@ -166,7 +158,7 @@ def test_client_error(self): client.get("/api/v3/brokerage/accounts") def test_server_error(self): - client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + client = RESTClient(api_key=TEST_API_KEY, api_secret=TEST_API_SECRET) with Mocker() as m: m.request( @@ -177,39 +169,3 @@ def test_server_error(self): with self.assertRaises(HTTPError): client.get("/api/v3/brokerage/accounts") - - def test_key_file_string(self): - try: - RESTClient(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"}' - ) - RESTClient(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"}') - RESTClient(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"}') - RESTClient( - 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"' - ) - RESTClient(key_file=key_file_object) diff --git a/tests/test_api_base.py b/tests/test_api_base.py new file mode 100644 index 0000000..57bc0e7 --- /dev/null +++ b/tests/test_api_base.py @@ -0,0 +1,56 @@ +import unittest +from io import StringIO + +from coinbase.api_base import APIBase +from tests.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) From 0b7522dbebf13eb2211ce93c9137a732fc74da2d Mon Sep 17 00:00:00 2001 From: urischwartz-cb <143205923+urischwartz-cb@users.noreply.github.com> Date: Mon, 29 Jan 2024 12:14:37 -0500 Subject: [PATCH 15/21] v1.0.4 (#20) --- CHANGELOG.md | 5 +++++ coinbase/__version__.py | 2 +- coinbase/rest/portfolios.py | 6 +++--- tests/rest/test_portfolios.py | 9 +++++---- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7dce74..13d9820 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [1.0.4] - 2024-JAN-29 + +### Fixed +- Fixed bug where `move_portfolio_funds` params were set incorrectly + ## [1.0.3] - 2024-JAN-19 ### Changed diff --git a/coinbase/__version__.py b/coinbase/__version__.py index 976498a..92192ee 100644 --- a/coinbase/__version__.py +++ b/coinbase/__version__.py @@ -1 +1 @@ -__version__ = "1.0.3" +__version__ = "1.0.4" diff --git a/coinbase/rest/portfolios.py b/coinbase/rest/portfolios.py index c75925d..c3135b1 100644 --- a/coinbase/rest/portfolios.py +++ b/coinbase/rest/portfolios.py @@ -59,11 +59,11 @@ def move_portfolio_funds( data = { "funds": { - "amount": value, + "value": value, "currency": currency, }, - "source_portfolio_id": source_portfolio_uuid, - "target_portfolio_id": target_portfolio_uuid, + "source_portfolio_uuid": source_portfolio_uuid, + "target_portfolio_uuid": target_portfolio_uuid, } return self.post(endpoint, data=data, **kwargs) diff --git a/tests/rest/test_portfolios.py b/tests/rest/test_portfolios.py index b18a934..d960735 100644 --- a/tests/rest/test_portfolios.py +++ b/tests/rest/test_portfolios.py @@ -3,7 +3,8 @@ from requests_mock import Mocker from coinbase.rest import RESTClient -from tests.constants import TEST_API_KEY, TEST_API_SECRET + +from ..constants import TEST_API_KEY, TEST_API_SECRET class PortfoliosTest(unittest.TestCase): @@ -83,9 +84,9 @@ def test_move_portfolio_funds(self): self.assertEqual( captured_json, { - "funds": {"amount": "100", "currency": "USD"}, - "source_portfolio_id": "1234", - "target_portfolio_id": "5678", + "funds": {"value": "100", "currency": "USD"}, + "source_portfolio_uuid": "1234", + "target_portfolio_uuid": "5678", }, ) self.assertEqual(move, expected_response) From 36f0f0230160b693bdde487819dcbcca4e2caaee Mon Sep 17 00:00:00 2001 From: urischwartz-cb <143205923+urischwartz-cb@users.noreply.github.com> Date: Wed, 31 Jan 2024 10:35:50 -0500 Subject: [PATCH 16/21] Release v1.1.0 (#21) --- CHANGELOG.md | 6 + README.md | 140 +++++++- coinbase/__version__.py | 2 +- coinbase/api_base.py | 18 +- coinbase/constants.py | 30 +- coinbase/rest/rest_base.py | 24 +- coinbase/websocket/__init__.py | 38 +++ coinbase/websocket/channels.py | 236 ++++++++++++++ coinbase/websocket/websocket_base.py | 434 +++++++++++++++++++++++++ requirements.txt | 4 +- test_requirements.txt | 3 +- tests/rest/test_accounts.py | 3 +- tests/rest/test_common.py | 3 +- tests/rest/test_convert.py | 3 +- tests/rest/test_fees.py | 3 +- tests/rest/test_futures.py | 3 +- tests/rest/test_market_data.py | 3 +- tests/rest/test_orders.py | 3 +- tests/rest/test_products.py | 3 +- tests/rest/test_rest_base.py | 3 +- tests/test_api_base.py | 3 +- tests/test_jwt_generator.py | 3 +- 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 ++++++++++++++++++++++ 26 files changed, 1595 insertions(+), 27 deletions(-) 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 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/CHANGELOG.md b/CHANGELOG.md index 13d9820..bcc46ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [1.1.0] - 2024-FEB-1 # TODO: Update this date + +### Added +- Initial release of WebSocket API client +- Verbose logging option for RESTClient + ## [1.0.4] - 2024-JAN-29 ### Fixed diff --git a/README.md b/README.md index f069fc5..bca9faa 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![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). ## Installation @@ -54,7 +55,7 @@ You can also set a timeout in seconds for your REST requests like so: client = RESTClient(api_key=api_key, api_secret=api_secret, timeout=5) ``` -### Using the Client +### Using the REST Client You are able to use any of the API hooks to make calls to the Coinbase API. For example: ```python @@ -96,8 +97,141 @@ 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. +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 @@ -128,7 +262,7 @@ 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](CHANGELOG.md). +For a detailed list of changes, see the [Changelog](https://github.com/coinbase/coinbase-advanced-py/blob/master/CHANGELOG.md). ## Contributing diff --git a/coinbase/__version__.py b/coinbase/__version__.py index 92192ee..6849410 100644 --- a/coinbase/__version__.py +++ b/coinbase/__version__.py @@ -1 +1 @@ -__version__ = "1.0.4" +__version__ = "1.1.0" diff --git a/coinbase/api_base.py b/coinbase/api_base.py index 4bb29a5..775e7fa 100644 --- a/coinbase/api_base.py +++ b/coinbase/api_base.py @@ -1,10 +1,25 @@ 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, @@ -12,7 +27,8 @@ def __init__( api_secret: Optional[str] = os.getenv(API_SECRET_ENV_KEY), key_file: Optional[Union[IO, str]] = None, base_url=None, - timeout=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") diff --git a/coinbase/constants.py b/coinbase/constants.py index 9212550..f5eda87 100644 --- a/coinbase/constants.py +++ b/coinbase/constants.py @@ -1,6 +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" -API_ENV_KEY = "COINBASE_API_KEY" -API_SECRET_ENV_KEY = "COINBASE_API_SECRET" + +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/rest/rest_base.py b/coinbase/rest/rest_base.py index 9d3b6b1..9501ab1 100644 --- a/coinbase/rest/rest_base.py +++ b/coinbase/rest/rest_base.py @@ -1,4 +1,4 @@ -import json +import logging import os from typing import IO, Optional, Union @@ -6,9 +6,10 @@ from requests.exceptions import HTTPError from coinbase import jwt_generator -from coinbase.__version__ import __version__ -from coinbase.api_base import APIBase -from coinbase.constants import API_ENV_KEY, API_SECRET_ENV_KEY, BASE_URL +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): @@ -33,6 +34,7 @@ def handle_exception(response): ) if http_error_msg: + logger.error(f"HTTP Error: {http_error_msg}") raise HTTPError(http_error_msg, response=response) @@ -43,7 +45,8 @@ def __init__( api_secret: Optional[str] = os.getenv(API_SECRET_ENV_KEY), key_file: Optional[Union[IO, str]] = None, base_url=BASE_URL, - timeout=None, + timeout: Optional[int] = None, + verbose: Optional[bool] = False, ): super().__init__( api_key=api_key, @@ -51,7 +54,10 @@ def __init__( 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): params = params or {} @@ -126,6 +132,8 @@ def send_request(self, http_method, url_path, params, headers, data=None): url = f"https://{self.base_url}{url_path}" + logger.debug(f"Sending {http_method} request to {url}") + response = requests.request( http_method, url, @@ -136,6 +144,8 @@ def send_request(self, http_method, url_path, params, headers, data=None): ) 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): @@ -143,6 +153,6 @@ def set_headers(self, method, path): jwt = jwt_generator.build_rest_jwt(uri, self.api_key, self.api_secret) return { "Content-Type": "application/json", - "Authorization": "Bearer " + jwt, - "User-Agent": "coinbase-advanced-py/" + __version__, + "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..d517af7 --- /dev/null +++ b/coinbase/websocket/channels.py @@ -0,0 +1,236 @@ +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]): + """ + Subscribe to heartbeats channel for a list of products_ids. + """ + self.subscribe(product_ids, [HEARTBEATS]) + + +async def heartbeats_async(self, product_ids: List[str]): + """ + Async subscribe to heartbeats channel for a list of products_ids. + """ + await self.subscribe_async(product_ids, [HEARTBEATS]) + + +def heartbeats_unsubscribe(self, product_ids: List[str]): + """ + Unsubscribe to heartbeats channel for a list of products_ids. + """ + self.unsubscribe(product_ids, [HEARTBEATS]) + + +async def heartbeats_unsubscribe_async(self, product_ids: List[str]): + """ + Async unsubscribe to heartbeats channel for a list of products_ids. + """ + await self.unsubscribe_async(product_ids, [HEARTBEATS]) + + +def candles(self, product_ids: List[str]): + """ + Subscribe to candles channel for a list of products_ids. + """ + self.subscribe(product_ids, [CANDLES]) + + +async def candles_async(self, product_ids: List[str]): + """ + Async subscribe to candles channel for a list of products_ids. + """ + await self.subscribe_async(product_ids, [CANDLES]) + + +def candles_unsubscribe(self, product_ids: List[str]): + """ + Unsubscribe to candles channel for a list of products_ids. + """ + self.unsubscribe(product_ids, [CANDLES]) + + +async def candles_unsubscribe_async(self, product_ids: List[str]): + """ + Async unsubscribe to candles channel for a list of products_ids. + """ + await self.unsubscribe_async(product_ids, [CANDLES]) + + +def market_trades(self, product_ids: List[str]): + """ + Subscribe to market_trades channel for a list of products_ids. + """ + self.subscribe(product_ids, [MARKET_TRADES]) + + +async def market_trades_async(self, product_ids: List[str]): + """ + Async subscribe to market_trades channel for a list of products_ids. + """ + await self.subscribe_async(product_ids, [MARKET_TRADES]) + + +def market_trades_unsubscribe(self, product_ids: List[str]): + """ + Unsubscribe to market_trades channel for a list of products_ids. + """ + self.unsubscribe(product_ids, [MARKET_TRADES]) + + +async def market_trades_unsubscribe_async(self, product_ids: List[str]): + """ + Async unsubscribe to market_trades channel for a list of products_ids. + """ + await self.unsubscribe_async(product_ids, [MARKET_TRADES]) + + +def status(self, product_ids: List[str]): + """ + Subscribe to status channel for a list of products_ids. + """ + self.subscribe(product_ids, [STATUS]) + + +async def status_async(self, product_ids: List[str]): + """ + Async subscribe to status channel for a list of products_ids. + """ + await self.subscribe_async(product_ids, [STATUS]) + + +def status_unsubscribe(self, product_ids: List[str]): + """ + Unsubscribe to status channel for a list of products_ids. + """ + self.unsubscribe(product_ids, [STATUS]) + + +async def status_unsubscribe_async(self, product_ids: List[str]): + """ + Async unsubscribe to status channel for a list of products_ids. + """ + await self.unsubscribe_async(product_ids, [STATUS]) + + +def ticker(self, product_ids: List[str]): + """ + Subscribe to ticker channel for a list of products_ids. + """ + self.subscribe(product_ids, [TICKER]) + + +async def ticker_async(self, product_ids: List[str]): + """ + Async subscribe to ticker channel for a list of products_ids. + """ + await self.subscribe_async(product_ids, [TICKER]) + + +def ticker_unsubscribe(self, product_ids: List[str]): + """ + Unsubscribe to ticker channel for a list of products_ids. + """ + self.unsubscribe(product_ids, [TICKER]) + + +async def ticker_unsubscribe_async(self, product_ids: List[str]): + """ + Async unsubscribe to ticker channel for a list of products_ids. + """ + await self.unsubscribe_async(product_ids, [TICKER]) + + +def ticker_batch(self, product_ids: List[str]): + """ + Subscribe to ticker_batch channel for a list of products_ids. + """ + self.subscribe(product_ids, [TICKER_BATCH]) + + +async def ticker_batch_async(self, product_ids: List[str]): + """ + Async subscribe to ticker_batch channel for a list of products_ids. + """ + await self.subscribe_async(product_ids, [TICKER_BATCH]) + + +def ticker_batch_unsubscribe(self, product_ids: List[str]): + """ + Unsubscribe to ticker_batch channel for a list of products_ids. + """ + self.unsubscribe(product_ids, [TICKER_BATCH]) + + +async def ticker_batch_unsubscribe_async(self, product_ids: List[str]): + """ + Async unsubscribe to ticker_batch channel for a list of products_ids. + """ + await self.unsubscribe_async(product_ids, [TICKER_BATCH]) + + +def level2(self, product_ids: List[str]): + """ + Subscribe to level2 channel for a list of products_ids. + """ + self.subscribe(product_ids, [LEVEL2]) + + +async def level2_async(self, product_ids: List[str]): + """ + Async subscribe to level2 channel for a list of products_ids. + """ + await self.subscribe_async(product_ids, [LEVEL2]) + + +def level2_unsubscribe(self, product_ids: List[str]): + """ + Unsubscribe to level2 channel for a list of products_ids. + """ + self.unsubscribe(product_ids, [LEVEL2]) + + +async def level2_unsubscribe_async(self, product_ids: List[str]): + """ + Async unsubscribe to level2 channel for a list of products_ids. + """ + await self.unsubscribe_async(product_ids, [LEVEL2]) + + +def user(self, product_ids: List[str]): + """ + Subscribe to user channel for a list of products_ids. + """ + self.subscribe(product_ids, [USER]) + + +async def user_async(self, product_ids: List[str]): + """ + Async subscribe to user channel for a list of products_ids. + """ + await self.subscribe_async(product_ids, [USER]) + + +def user_unsubscribe(self, product_ids: List[str]): + """ + Unsubscribe to user channel for a list of products_ids. + """ + self.unsubscribe(product_ids, [USER]) + + +async def user_unsubscribe_async(self, product_ids: List[str]): + """ + Async unsubscribe to user channel for a list of products_ids. + """ + 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..6eeadc8 --- /dev/null +++ b/coinbase/websocket/websocket_base.py @@ -0,0 +1,434 @@ +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): + """Exception raised for errors in the WebSocket client.""" + + pass + + +class WSClientConnectionClosedException(Exception): + """Exception raised for unexpected closure in the WebSocket client.""" + + pass + + +class WSBase(APIBase): + 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): + """ + 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): + """ + 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): + """ + 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): + """ + 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]): + """ + Subscribe to a list of channels for a list of product ids. + :param product_ids: product ids to subscribe to + :param 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]): + """ + Async subscribe to a list of channels for a list of product ids. + :param product_ids: product ids to subscribe to + :param 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]): + """ + Unsubscribe to a list of channels for a list of product ids. + :param product_ids: product ids to unsubscribe from + :param 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]): + """ + Async unsubscribe to a list of channels for a list of product ids. + :param product_ids: product ids to unsubscribe from + :param 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): + """ + 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): + """ + 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): + """ + Sleep for a specified number of seconds and check for background exceptions. + :param sleep: number of seconds to sleep. + """ + time.sleep(sleep) + self.raise_background_exception() + + async def sleep_with_exception_check_async(self, sleep: int): + """ + Async sleep for a specified number of seconds and check for background exceptions. + """ + await asyncio.sleep(sleep) + self.raise_background_exception() + + def run_forever_with_exception_check(self): + """ + 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): + """ + 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): + """ + 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): + future = asyncio.run_coroutine_threadsafe(coro, self.loop) + return future.result() + + def _is_websocket_open(self): + return self.websocket and self.websocket.open + + async def _resubscribe(self): + for channel, product_ids in self.subscriptions.items(): + await self.subscribe_async(list(product_ids), [channel]) + + async def _retry_connection(self): + 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): + 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 + ): + 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): + if self._is_websocket_open(): + raise WSClientException("WebSocket is already open.") + + def _ensure_websocket_open(self): + if not self._is_websocket_open(): + raise WSClientException("WebSocket is closed or was never opened.") + + def _set_headers(self): + if self._retry_count > 0: + return {"x-cb-retry-counter": str(self._retry_count)} + else: + return {} diff --git a/requirements.txt b/requirements.txt index 0098735..fcb2c00 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,6 @@ requests==2.31.0 black==23.3.0 isort==5.12.0 cryptography==41.0.6 -PyJWT==2.8.0 \ No newline at end of file +PyJWT==2.8.0 +websockets==12.0 +backoff==2.2.1 \ No newline at end of file diff --git a/test_requirements.txt b/test_requirements.txt index b03b03f..a3e8fb1 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -1 +1,2 @@ -requests-mock==1.11.0 \ No newline at end of file +requests-mock==1.11.0 +asynctest==0.13.0 \ No newline at end of file diff --git a/tests/rest/test_accounts.py b/tests/rest/test_accounts.py index 5366ef3..bd7a94b 100644 --- a/tests/rest/test_accounts.py +++ b/tests/rest/test_accounts.py @@ -3,7 +3,8 @@ from requests_mock import Mocker from coinbase.rest import RESTClient -from tests.constants import TEST_API_KEY, TEST_API_SECRET + +from ..constants import TEST_API_KEY, TEST_API_SECRET class AccountsTest(unittest.TestCase): diff --git a/tests/rest/test_common.py b/tests/rest/test_common.py index fc4f8ca..b33af4a 100644 --- a/tests/rest/test_common.py +++ b/tests/rest/test_common.py @@ -3,7 +3,8 @@ from requests_mock import Mocker from coinbase.rest import RESTClient -from tests.constants import TEST_API_KEY, TEST_API_SECRET + +from ..constants import TEST_API_KEY, TEST_API_SECRET class TimeTest(unittest.TestCase): diff --git a/tests/rest/test_convert.py b/tests/rest/test_convert.py index 7a8ae4a..b0e006c 100644 --- a/tests/rest/test_convert.py +++ b/tests/rest/test_convert.py @@ -3,7 +3,8 @@ from requests_mock import Mocker from coinbase.rest import RESTClient -from tests.constants import TEST_API_KEY, TEST_API_SECRET + +from ..constants import TEST_API_KEY, TEST_API_SECRET class ConvertTest(unittest.TestCase): diff --git a/tests/rest/test_fees.py b/tests/rest/test_fees.py index 7d8e933..b39581b 100644 --- a/tests/rest/test_fees.py +++ b/tests/rest/test_fees.py @@ -3,7 +3,8 @@ from requests_mock import Mocker from coinbase.rest import RESTClient -from tests.constants import TEST_API_KEY, TEST_API_SECRET + +from ..constants import TEST_API_KEY, TEST_API_SECRET class FeesTest(unittest.TestCase): diff --git a/tests/rest/test_futures.py b/tests/rest/test_futures.py index f1a0fa7..733cb95 100644 --- a/tests/rest/test_futures.py +++ b/tests/rest/test_futures.py @@ -3,7 +3,8 @@ from requests_mock import Mocker from coinbase.rest import RESTClient -from tests.constants import TEST_API_KEY, TEST_API_SECRET + +from ..constants import TEST_API_KEY, TEST_API_SECRET class FuturesTest(unittest.TestCase): diff --git a/tests/rest/test_market_data.py b/tests/rest/test_market_data.py index 81610db..717fedb 100644 --- a/tests/rest/test_market_data.py +++ b/tests/rest/test_market_data.py @@ -3,7 +3,8 @@ from requests_mock import Mocker from coinbase.rest import RESTClient -from tests.constants import TEST_API_KEY, TEST_API_SECRET + +from ..constants import TEST_API_KEY, TEST_API_SECRET class MarketDataTest(unittest.TestCase): diff --git a/tests/rest/test_orders.py b/tests/rest/test_orders.py index c6009c9..8cbce28 100644 --- a/tests/rest/test_orders.py +++ b/tests/rest/test_orders.py @@ -3,7 +3,8 @@ from requests_mock import Mocker from coinbase.rest import RESTClient -from tests.constants import TEST_API_KEY, TEST_API_SECRET + +from ..constants import TEST_API_KEY, TEST_API_SECRET class OrdersTest(unittest.TestCase): diff --git a/tests/rest/test_products.py b/tests/rest/test_products.py index 91c8138..cac983b 100644 --- a/tests/rest/test_products.py +++ b/tests/rest/test_products.py @@ -3,7 +3,8 @@ from requests_mock import Mocker from coinbase.rest import RESTClient -from tests.constants import TEST_API_KEY, TEST_API_SECRET + +from ..constants import TEST_API_KEY, TEST_API_SECRET class ProductsTest(unittest.TestCase): diff --git a/tests/rest/test_rest_base.py b/tests/rest/test_rest_base.py index 135a0d3..07dc3d7 100644 --- a/tests/rest/test_rest_base.py +++ b/tests/rest/test_rest_base.py @@ -5,7 +5,8 @@ from coinbase.__version__ import __version__ from coinbase.rest import RESTClient -from tests.constants import TEST_API_KEY, TEST_API_SECRET + +from ..constants import TEST_API_KEY, TEST_API_SECRET class RestBaseTest(unittest.TestCase): diff --git a/tests/test_api_base.py b/tests/test_api_base.py index 57bc0e7..f0fefb9 100644 --- a/tests/test_api_base.py +++ b/tests/test_api_base.py @@ -2,7 +2,8 @@ from io import StringIO from coinbase.api_base import APIBase -from tests.constants import TEST_API_KEY, TEST_API_SECRET + +from .constants import TEST_API_KEY, TEST_API_SECRET class RestBaseTest(unittest.TestCase): diff --git a/tests/test_jwt_generator.py b/tests/test_jwt_generator.py index 2c9c126..3e9e6ac 100644 --- a/tests/test_jwt_generator.py +++ b/tests/test_jwt_generator.py @@ -6,7 +6,8 @@ from coinbase import jwt_generator from coinbase.constants import REST_SERVICE, WS_SERVICE -from tests.constants import TEST_API_KEY, TEST_API_SECRET + +from .constants import TEST_API_KEY, TEST_API_SECRET class JwtGeneratorTest(unittest.TestCase): 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() From f894ff1c9b7a925fdf5e0f4b1627730bdd005bdc Mon Sep 17 00:00:00 2001 From: urischwartz-cb <143205923+urischwartz-cb@users.noreply.github.com> Date: Wed, 31 Jan 2024 10:52:19 -0500 Subject: [PATCH 17/21] Update CHANGELOG.md (#22) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bcc46ee..b5dfaf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [1.1.0] - 2024-FEB-1 # TODO: Update this date +## [1.1.0] - 2024-JAN-31 ### Added - Initial release of WebSocket API client From 159761d97f6b842bd5505c19c127eae86b44ffa2 Mon Sep 17 00:00:00 2001 From: urischwartz-cb <143205923+urischwartz-cb@users.noreply.github.com> Date: Thu, 1 Feb 2024 14:30:55 -0500 Subject: [PATCH 18/21] Release v1.1.1 (#23) --- CHANGELOG.md | 5 ++ coinbase/__version__.py | 2 +- coinbase/rest/__init__.py | 6 +++ coinbase/rest/perpetuals.py | 56 ++++++++++++++++++++ tests/rest/test_perpetuals.py | 98 +++++++++++++++++++++++++++++++++++ 5 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 coinbase/rest/perpetuals.py create mode 100644 tests/rest/test_perpetuals.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b5dfaf8..a0d918f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [1.1.1] - 2024-FEB-1 + +### Added +- Support for Perpetuals API endpoints + ## [1.1.0] - 2024-JAN-31 ### Added diff --git a/coinbase/__version__.py b/coinbase/__version__.py index 6849410..a82b376 100644 --- a/coinbase/__version__.py +++ b/coinbase/__version__.py @@ -1 +1 @@ -__version__ = "1.1.0" +__version__ = "1.1.1" diff --git a/coinbase/rest/__init__.py b/coinbase/rest/__init__.py index 842ad9e..37b5fe7 100755 --- a/coinbase/rest/__init__.py +++ b/coinbase/rest/__init__.py @@ -39,6 +39,12 @@ class RESTClient(RESTBase): 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, diff --git a/coinbase/rest/perpetuals.py b/coinbase/rest/perpetuals.py new file mode 100644 index 0000000..8de5b03 --- /dev/null +++ b/coinbase/rest/perpetuals.py @@ -0,0 +1,56 @@ +from typing import Optional + +from coinbase.constants import API_PREFIX + + +def allocate_portfolio( + self, portfolio_uuid: str, symbol: str, amount: str, currency: str, **kwargs +): + """ + Allocate more funds to an isolated position in your Perpetuals portfolio. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_allocateportfolio + """ + 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): + """ + Get a summary of your Perpetuals portfolio. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getintxportfoliosummary + """ + endpoint = f"{API_PREFIX}/intx/portfolio/{portfolio_uuid}" + + return self.get(endpoint, **kwargs) + + +def list_perps_positions(self, portfolio_uuid: str, **kwargs): + """ + Get a list of open positions in your Perpetuals portfolio. + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getintxpositions + """ + endpoint = f"{API_PREFIX}/intx/positions/{portfolio_uuid}" + + return self.get(endpoint, **kwargs) + + +def get_perps_position(self, portfolio_uuid: str, symbol: str, **kwargs): + """ + Get a specific open position in your Perpetuals portfolio + + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getintxposition + """ + endpoint = f"{API_PREFIX}/intx/positions/{portfolio_uuid}/{symbol}" + + return self.get(endpoint, **kwargs) 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) From 63610a00e85cc5a94581dac9d6299b25d457cf81 Mon Sep 17 00:00:00 2001 From: urischwartz-cb <143205923+urischwartz-cb@users.noreply.github.com> Date: Mon, 12 Feb 2024 14:30:48 -0500 Subject: [PATCH 19/21] v1.1.2 (#25) * Release v1.1.2 * update build.yml, add pages.yml * modify .github * fix typo in pages.yml * change branch name for testing * revert branch name change * modify pages.yml and build.yml * test commit * change github pages branch name for testing * modify readme, revert branch name * typo in pages.yml --------- Co-authored-by: David Mkrtchyan --- .github/workflows/build.yml | 10 +- .github/workflows/pages.yml | 50 ++ CHANGELOG.md | 8 +- README.md | 2 + coinbase/__version__.py | 2 +- coinbase/jwt_generator.py | 50 ++ coinbase/rest/__init__.py | 16 + coinbase/rest/accounts.py | 26 +- coinbase/rest/common.py | 12 +- coinbase/rest/convert.py | 39 +- coinbase/rest/fees.py | 13 +- coinbase/rest/futures.py | 86 +- coinbase/rest/market_data.py | 28 +- coinbase/rest/orders.py | 1145 +++++++++++++++++++++++++- coinbase/rest/perpetuals.py | 57 +- coinbase/rest/portfolios.py | 84 +- coinbase/rest/products.py | 59 +- coinbase/rest/rest_base.py | 83 +- coinbase/websocket/channels.py | 384 +++++++++ coinbase/websocket/websocket_base.py | 170 +++- 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 | 12 +- setup.py | 4 + tests/rest/test_orders.py | 636 +++++++++++++- 33 files changed, 3180 insertions(+), 88 deletions(-) create mode 100644 .github/workflows/pages.yml 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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c9d97ef..57e2cae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,7 +13,7 @@ jobs: - name: Install dependencies and format code run: | sudo apt-get install -y make - pip3 install black isort + pip3 install -r lint_requirements.txt make format if git diff --quiet; then @@ -40,8 +40,8 @@ jobs: run: | python -m venv venv source venv/bin/activate - pip install --upgrade pip - pip install -r requirements.txt - pip install -r test_requirements.txt + pip3 install --upgrade pip + pip3 install -r requirements.txt + pip3 install -r test_requirements.txt - python -m unittest discover -v + python -m unittest discover -v \ No newline at end of file diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..fa3a432 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,50 @@ +# Simple workflow for deploying static content to GitHub Pages +name: Github Pages + +on: + # Runs on pushes targeting the default branch + push: + branches: ["master"] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Install Dependencies + run: | + pip3 install -r pinned_requirements.txt + pip3 install -r docs_requirements.txt + - name: Build HTML + run: | + cd docs + make html + - name: Setup Pages + uses: actions/configure-pages@v3 + - name: Upload artifact + uses: actions/upload-pages-artifact@v1 + with: + # Upload the build output directory + path: 'docs/_build/html' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index a0d918f..8bdd750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [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 @@ -35,4 +41,4 @@ ## [1.0.0] - 2023-DEC-18 ### Added -- Initial release of the Coinbase Advanced Trading API Python SDK +- Initial release of the Coinbase Advanced Trading API Python SDK \ No newline at end of file diff --git a/README.md b/README.md index bca9faa..729c4a5 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ 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 diff --git a/coinbase/__version__.py b/coinbase/__version__.py index a82b376..72f26f5 100644 --- a/coinbase/__version__.py +++ b/coinbase/__version__.py @@ -1 +1 @@ -__version__ = "1.1.1" +__version__ = "1.1.2" diff --git a/coinbase/jwt_generator.py b/coinbase/jwt_generator.py index 274e0ab..0517d6f 100644 --- a/coinbase/jwt_generator.py +++ b/coinbase/jwt_generator.py @@ -8,6 +8,9 @@ def build_jwt(key_var, secret_var, service, uri=None): + """ + :meta private: + """ try: private_key_bytes = secret_var.encode("utf-8") private_key = serialization.load_pem_private_key( @@ -42,12 +45,59 @@ def build_jwt(key_var, secret_var, service, uri=None): def build_rest_jwt(uri, key_var, secret_var): + """ + **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): + """ + **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): + """ + **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 index 37b5fe7..3d0757e 100755 --- a/coinbase/rest/__init__.py +++ b/coinbase/rest/__init__.py @@ -32,6 +32,22 @@ class RESTClient(RESTBase): 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, diff --git a/coinbase/rest/accounts.py b/coinbase/rest/accounts.py index ce4a88a..f2f1845 100644 --- a/coinbase/rest/accounts.py +++ b/coinbase/rest/accounts.py @@ -7,9 +7,20 @@ def get_accounts( self, limit: Optional[int] = None, cursor: Optional[str] = None, **kwargs ): """ + **List Accounts** + _________________ + [GET] https://api.coinbase.com/api/v3/brokerage/accounts + + __________ + + **Description:** + Get a list of authenticated accounts for the current user. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getaccounts + __________ + + **Read more on the official documentation:** `List Accounts `_ + """ endpoint = f"{API_PREFIX}/accounts" params = {"limit": limit, "cursor": cursor} @@ -19,9 +30,20 @@ def get_accounts( def get_account(self, account_uuid: str, **kwargs): """ + + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getaccount + __________ + + **Read more on the official documentation:** `Get Account `_ """ endpoint = f"{API_PREFIX}/accounts/{account_uuid}" diff --git a/coinbase/rest/common.py b/coinbase/rest/common.py index 52a956b..7976c64 100644 --- a/coinbase/rest/common.py +++ b/coinbase/rest/common.py @@ -3,9 +3,19 @@ def get_unix_time(self, **kwargs): """ + **Get UNIX Time** + _________________ + [GET] https://api.coinbase.com/api/v3/brokerage/time + + __________ + + **Description:** + Get the current time from the Coinbase Advanced API. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getunixtime + __________ + + **Read more on the official documentation:** `Get UNIX Time `_ """ endpoint = f"{API_PREFIX}/time" diff --git a/coinbase/rest/convert.py b/coinbase/rest/convert.py index 5fb0bfe..581b53f 100644 --- a/coinbase/rest/convert.py +++ b/coinbase/rest/convert.py @@ -13,9 +13,20 @@ def create_convert_quote( **kwargs, ): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_createconvertquote + __________ + + **Read more on the official documentation:** `Create Convert Quote `_ """ endpoint = f"{API_PREFIX}/convert/quote" @@ -45,9 +56,20 @@ def get_convert_trade( self, trade_id: str, from_account: str, to_account: str, **kwargs ): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getconverttrade + __________ + + **Read more on the official documentation:** `Get Convert Trade `_ """ endpoint = f"{API_PREFIX}/convert/trade/{trade_id}" @@ -63,9 +85,20 @@ def commit_convert_trade( self, trade_id: str, from_account: str, to_account: str, **kwargs ): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_commitconverttrade + __________ + + **Read more on the official documentation:** `Commit Convert Trade `_ """ endpoint = f"{API_PREFIX}/convert/trade/{trade_id}" diff --git a/coinbase/rest/fees.py b/coinbase/rest/fees.py index 05861be..b0d3d71 100644 --- a/coinbase/rest/fees.py +++ b/coinbase/rest/fees.py @@ -10,9 +10,20 @@ def get_transaction_summary( **kwargs, ): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_gettransactionsummary + __________ + + **Read more on the official documentation:** `Create Convert Quote `_ """ endpoint = f"{API_PREFIX}/transaction_summary" diff --git a/coinbase/rest/futures.py b/coinbase/rest/futures.py index 926e417..039e29a 100644 --- a/coinbase/rest/futures.py +++ b/coinbase/rest/futures.py @@ -3,9 +3,21 @@ def get_futures_balance_summary(self, **kwargs): """ - Get information on your balances related to Coinbase Financial Markets (CFM) futures trading. + **Get Futures Balance Summary** + _______________________________ - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getfcmbalancesummary + [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" @@ -14,9 +26,21 @@ def get_futures_balance_summary(self, **kwargs): def list_futures_positions(self, **kwargs): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getfcmpositions + __________ + + **Read more on the official documentation:** `List Futures Positions + `_ """ endpoint = f"{API_PREFIX}/cfm/positions" @@ -25,9 +49,21 @@ def list_futures_positions(self, **kwargs): def get_futures_position(self, product_id: str, **kwargs): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getfcmposition + __________ + + **Read more on the official documentation:** `Get Futures Position + `_ """ endpoint = f"{API_PREFIX}/cfm/positions/{product_id}" @@ -36,9 +72,21 @@ def get_futures_position(self, product_id: str, **kwargs): def schedule_futures_sweep(self, usd_amount: str, **kwargs): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_schedulefcmsweep + __________ + + **Read more on the official documentation:** `Schedule Futures Sweep + `_ """ endpoint = f"{API_PREFIX}/cfm/sweeps/schedule" @@ -49,9 +97,21 @@ def schedule_futures_sweep(self, usd_amount: str, **kwargs): def list_futures_sweeps(self, **kwargs): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getfcmsweeps + __________ + + **Read more on the official documentation:** `List Futures Sweeps + `_ """ endpoint = f"{API_PREFIX}/cfm/sweeps" @@ -60,9 +120,21 @@ def list_futures_sweeps(self, **kwargs): def cancel_pending_futures_sweep(self, **kwargs): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_cancelfcmsweep + __________ + + **Read more on the official documentation:** `Cancel Pending Futures Sweep + `_ """ endpoint = f"{API_PREFIX}/cfm/sweeps" diff --git a/coinbase/rest/market_data.py b/coinbase/rest/market_data.py index 2ae5ff7..690822f 100644 --- a/coinbase/rest/market_data.py +++ b/coinbase/rest/market_data.py @@ -7,9 +7,21 @@ def get_candles( self, product_id: str, start: str, end: str, granularity: str, **kwargs ): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getcandles + __________ + + **Read more on the official documentation:** `Get Product Candles + `_ """ endpoint = f"{API_PREFIX}/products/{product_id}/candles" @@ -31,9 +43,21 @@ def get_market_trades( **kwargs, ): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getmarkettrades + __________ + + **Read more on the official documentation:** `Get Market Trades + `_ """ endpoint = f"{API_PREFIX}/products/{product_id}/ticker" diff --git a/coinbase/rest/orders.py b/coinbase/rest/orders.py index aea8513..05d969d 100644 --- a/coinbase/rest/orders.py +++ b/coinbase/rest/orders.py @@ -16,9 +16,21 @@ def create_order( **kwargs, ): """ - Create an order with a specified product_id (asset-pair), side (buy/sell), etc. + **Create Order** + ________________ - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + [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" @@ -51,10 +63,22 @@ def market_order( **kwargs, ): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + __________ + + **Read more on the official documentation:** `Create Order + `_ """ market_market_ioc = {"quote_size": quote_size, "base_size": base_size} @@ -90,9 +114,21 @@ def market_order_buy( **kwargs, ): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + __________ + + **Read more on the official documentation:** `Create Order + `_ """ return market_order( self, @@ -120,9 +156,21 @@ def market_order_sell( **kwargs, ): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + __________ + + **Read more on the official documentation:** `Create Order + `_ """ return market_order( self, @@ -154,10 +202,22 @@ def limit_order_gtc( **kwargs, ): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + __________ + + **Read more on the official documentation:** `Create Order + `_ """ order_configuration = { "limit_limit_gtc": { @@ -195,10 +255,22 @@ def limit_order_gtc_buy( **kwargs, ): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + __________ + + **Read more on the official documentation:** `Create Order + `_ """ return limit_order_gtc( self, @@ -230,10 +302,22 @@ def limit_order_gtc_sell( **kwargs, ): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + __________ + + **Read more on the official documentation:** `Create Order + `_ """ return limit_order_gtc( self, @@ -268,10 +352,22 @@ def limit_order_gtd( **kwargs, ): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + __________ + + **Read more on the official documentation:** `Create Order + `_ """ order_configuration = { "limit_limit_gtd": { @@ -311,10 +407,22 @@ def limit_order_gtd_buy( **kwargs, ): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + __________ + + **Read more on the official documentation:** `Create Order + `_ """ return limit_order_gtd( self, @@ -348,10 +456,22 @@ def limit_order_gtd_sell( **kwargs, ): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + __________ + + **Read more on the official documentation:** `Create Order + `_ """ return limit_order_gtd( self, @@ -387,10 +507,22 @@ def stop_limit_order_gtc( **kwargs, ): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + __________ + + **Read more on the official documentation:** `Create Order + `_ """ order_configuration = { "stop_limit_stop_limit_gtc": { @@ -430,10 +562,22 @@ def stop_limit_order_gtc_buy( **kwargs, ): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + __________ + + **Read more on the official documentation:** `Create Order + `_ """ return stop_limit_order_gtc( self, @@ -467,10 +611,22 @@ def stop_limit_order_gtc_sell( **kwargs, ): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + __________ + + **Read more on the official documentation:** `Create Order + `_ """ return stop_limit_order_gtc( self, @@ -507,10 +663,22 @@ def stop_limit_order_gtd( **kwargs, ): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + __________ + + **Read more on the official documentation:** `Create Order + `_ """ order_configuration = { "stop_limit_stop_limit_gtd": { @@ -552,10 +720,22 @@ def stop_limit_order_gtd_buy( **kwargs, ): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + __________ + + **Read more on the official documentation:** `Create Order + `_ """ return stop_limit_order_gtd( self, @@ -591,10 +771,22 @@ def stop_limit_order_gtd_sell( **kwargs, ): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_postorder + __________ + + **Read more on the official documentation:** `Create Order + `_ """ return stop_limit_order_gtd( self, @@ -616,9 +808,21 @@ def stop_limit_order_gtd_sell( def get_order(self, order_id: str, **kwargs): """ + **Get Order** + _____________ + + [GET] https://api.coinbase.com/api/v3/brokerage/orders/historical/{order_id} + + __________ + + **Description:** + Get a single order by order ID. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_gethistoricalorder + __________ + + **Read more on the official documentation:** `Get Order + `_ """ endpoint = f"{API_PREFIX}/orders/historical/{order_id}" @@ -643,9 +847,21 @@ def list_orders( **kwargs, ): """ - Get a list of orders filtered by optional query parameters (product_id, order_status, etc). + **List Orders** + _______________ + + [GET] https://api.coinbase.com/api/v3/brokerage/orders/historical/batch + + __________ + + **Description:** - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_gethistoricalorders + 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 = { @@ -678,9 +894,21 @@ def get_fills( **kwargs, ): """ - Get a list of fills filtered by optional query parameters (product_id, order_id, etc). + **List Fills** + ______________ + + [GET] https://api.coinbase.com/api/v3/brokerage/orders/historical/fills + + __________ - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getfills + **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 = { @@ -703,10 +931,21 @@ def edit_order( **kwargs, ): """ - 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. + **Edit Order** + ______________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/edit - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_editorder + __________ + + **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 = { @@ -726,10 +965,21 @@ def preview_edit_order( **kwargs, ): """ - 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. + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_previeweditorder + __________ + + **Read more on the official documentation:** `Edit Order Preview + `_ """ endpoint = f"{API_PREFIX}/orders/edit_preview" data = { @@ -743,9 +993,21 @@ def preview_edit_order( def cancel_orders(self, order_ids: List[str], **kwargs): """ + **Cancel Orders** + _________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/batch_cancel + + __________ + + **Description:** + Initiate cancel requests for one or more orders. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_cancelorders + __________ + + **Read more on the official documentation:** `Cancel Orders + `_ """ endpoint = f"{API_PREFIX}/orders/batch_cancel" data = { @@ -753,3 +1015,828 @@ def cancel_orders(self, order_ids: List[str], **kwargs): } 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, +): + """ + **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, +): + """ + **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, +): + """ + **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, +): + """ + **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, +): + """ + **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, +): + """ + **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, +): + """ + **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, +): + """ + **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, +): + """ + **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, +): + """ + **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, +): + """ + **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, +): + """ + **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, +): + """ + **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, +): + """ + **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, +): + """ + **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, +): + """ + **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 index 8de5b03..c0be6f5 100644 --- a/coinbase/rest/perpetuals.py +++ b/coinbase/rest/perpetuals.py @@ -7,10 +7,23 @@ def allocate_portfolio( self, portfolio_uuid: str, symbol: str, amount: str, currency: str, **kwargs ): """ + **Allocate Portfolio** + ________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/intx/allocate + + __________ + + **Description:** + Allocate more funds to an isolated position in your Perpetuals portfolio. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_allocateportfolio + __________ + + **Read more on the official documentation:** `Allocate Portfolio + `_ """ + endpoint = f"{API_PREFIX}/intx/allocate" data = { @@ -25,9 +38,21 @@ def allocate_portfolio( def get_perps_portfolio_summary(self, portfolio_uuid: str, **kwargs): """ + **Get Perpetuals Portfolio Summary** + ________________ + + [GET] https://api.coinbase.com/api/v3/brokerage/intx/portfolio/{portfolio_uuid} + + __________ + + **Description:** + Get a summary of your Perpetuals portfolio. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getintxportfoliosummary + __________ + + **Read more on the official documentation:** `Get Perpetuals Portfolio Summary + `_ """ endpoint = f"{API_PREFIX}/intx/portfolio/{portfolio_uuid}" @@ -36,9 +61,21 @@ def get_perps_portfolio_summary(self, portfolio_uuid: str, **kwargs): def list_perps_positions(self, portfolio_uuid: str, **kwargs): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getintxpositions + __________ + + **Read more on the official documentation:** `List Perpetuals Positions + `_ """ endpoint = f"{API_PREFIX}/intx/positions/{portfolio_uuid}" @@ -47,9 +84,21 @@ def list_perps_positions(self, portfolio_uuid: str, **kwargs): def get_perps_position(self, portfolio_uuid: str, symbol: str, **kwargs): """ + **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 - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getintxposition + __________ + + **Read more on the official documentation:** `Get Perpetuals Positions + `_ """ endpoint = f"{API_PREFIX}/intx/positions/{portfolio_uuid}/{symbol}" diff --git a/coinbase/rest/portfolios.py b/coinbase/rest/portfolios.py index c3135b1..ce3aa44 100644 --- a/coinbase/rest/portfolios.py +++ b/coinbase/rest/portfolios.py @@ -5,9 +5,21 @@ def get_portfolios(self, portfolio_type: Optional[str] = None, **kwargs): """ + **List Portfolios** + ___________________ + + [GET] https://api.coinbase.com/api/v3/brokerage/portfolios + + __________ + + **Description:** + Get a list of all portfolios of a user. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getportfolios + __________ + + **Read more on the official documentation:** `List Portfolios + `_ """ endpoint = f"{API_PREFIX}/portfolios" @@ -18,9 +30,21 @@ def get_portfolios(self, portfolio_type: Optional[str] = None, **kwargs): def create_portfolio(self, name: str, **kwargs): """ + **Create Portfolio** + ____________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/portfolios + + __________ + + **Description:** + Create a portfolio. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_createportfolio + __________ + + **Read more on the official documentation:** `Create Portfolio + `_ """ endpoint = f"{API_PREFIX}/portfolios" @@ -33,9 +57,21 @@ def create_portfolio(self, name: str, **kwargs): def get_portfolio_breakdown(self, portfolio_uuid: str, **kwargs): """ + **Get Portfolio Breakdown** + ___________________________ + + [GET] https://api.coinbase.com/api/v3/brokerage/portfolios/{portfolio_uuid} + + __________ + + **Description:** + Get the breakdown of a portfolio by portfolio ID. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getportfoliobreakdown + __________ + + **Read more on the official documentation:** `Get Portfolio Breakdown + `_ """ endpoint = f"{API_PREFIX}/portfolios/{portfolio_uuid}" @@ -51,9 +87,21 @@ def move_portfolio_funds( **kwargs, ): """ + **Move Portfolio Funds** + ________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/portfolios/move_funds + + __________ + + **Description:** + Transfer funds between portfolios. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_moveportfoliofunds + __________ + + **Read more on the official documentation:** `Move Portfolio Funds + `_ """ endpoint = f"{API_PREFIX}/portfolios/move_funds" @@ -71,9 +119,21 @@ def move_portfolio_funds( def edit_portfolio(self, portfolio_uuid: str, name: str, **kwargs): """ + **Edit Portfolio** + __________________ + + [PUT] https://api.coinbase.com/api/v3/brokerage/portfolios/{portfolio_uuid} + + __________ + + **Description:** + Modify a portfolio by portfolio ID. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_editportfolio + __________ + + **Read more on the official documentation:** `Edit Portfolio + `_ """ endpoint = f"{API_PREFIX}/portfolios/{portfolio_uuid}" @@ -86,9 +146,21 @@ def edit_portfolio(self, portfolio_uuid: str, name: str, **kwargs): def delete_portfolio(self, portfolio_uuid: str, **kwargs): """ + **Delete Portfolio** + ____________________ + + [DELETE] https://api.coinbase.com/api/v3/brokerage/portfolios/{portfolio_uuid} + + __________ + + **Description:** + Delete a portfolio by portfolio ID. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_deleteportfolio + __________ + + **Read more on the official documentation:** `Delete Portfolio + `_ """ endpoint = f"{API_PREFIX}/portfolios/{portfolio_uuid}" diff --git a/coinbase/rest/products.py b/coinbase/rest/products.py index 695ebfd..6a850a6 100644 --- a/coinbase/rest/products.py +++ b/coinbase/rest/products.py @@ -14,9 +14,21 @@ def get_products( **kwargs, ): """ + **List Products** + _________________ + + [GET] https://api.coinbase.com/api/v3/brokerage/products + + __________ + + **Description:** + Get a list of the available currency pairs for trading. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getproducts + __________ + + **Read more on the official documentation:** `List Products + `_ """ endpoint = f"{API_PREFIX}/products" @@ -34,9 +46,21 @@ def get_products( def get_product(self, product_id: str, **kwargs): """ + **Get Product** + _______________ + + [GET] https://api.coinbase.com/api/v3/brokerage/products/{product_id} + + __________ + + **Description:** + Get information on a single product by product ID. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getproduct + __________ + + **Read more on the official documentation:** `Get Product + `_ """ endpoint = f"{API_PREFIX}/products/{product_id}" @@ -45,9 +69,21 @@ def get_product(self, product_id: str, **kwargs): def get_product_book(self, product_id: str, limit: Optional[int] = None, **kwargs): """ + **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. - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getproductbook + __________ + + **Read more on the official documentation:** `Get Product Book + `_ """ endpoint = f"{API_PREFIX}/product_book" @@ -58,10 +94,21 @@ def get_product_book(self, product_id: str, limit: Optional[int] = None, **kwarg def get_best_bid_ask(self, product_ids: Optional[List[str]] = None, **kwargs): """ - Get the best bid/ask for all products. A subset of all products can be returned instead by using the product_ids - input. + **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. + + __________ - https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getbestbidask + **Read more on the official documentation:** `Get Best Bid/Ask + `_ """ endpoint = f"{API_PREFIX}/best_bid_ask" diff --git a/coinbase/rest/rest_base.py b/coinbase/rest/rest_base.py index 9501ab1..3505b69 100644 --- a/coinbase/rest/rest_base.py +++ b/coinbase/rest/rest_base.py @@ -13,8 +13,10 @@ def handle_exception(response): - """Raises :class:`HTTPError`, if one occurred.""" + """Raises :class:`HTTPError`, if one occurred. + :meta private: + """ http_error_msg = "" reason = response.reason @@ -39,6 +41,26 @@ def handle_exception(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), @@ -60,6 +82,20 @@ def __init__( logger.setLevel(logging.DEBUG) def get(self, url_path, params: Optional[dict] = None, **kwargs): + """ + **Authenticated GET Request** + _____________________________ + + __________ + + **Parameters:** + + - **url_path | (str)** - the URL path + - **params | Optional ([dict])** - the query parameters + + + """ + params = params or {} if kwargs: @@ -74,6 +110,18 @@ def post( data: Optional[dict] = None, **kwargs, ): + """ + **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: @@ -88,6 +136,18 @@ def put( data: Optional[dict] = None, **kwargs, ): + """ + **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: @@ -102,6 +162,18 @@ def delete( data: Optional[dict] = None, **kwargs, ): + """ + **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: @@ -116,6 +188,9 @@ def prepare_and_send_request( params: Optional[dict] = None, data: Optional[dict] = None, ): + """ + :meta private: + """ headers = self.set_headers(http_method, url_path) if params is not None: @@ -127,6 +202,9 @@ def prepare_and_send_request( 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 = {} @@ -149,6 +227,9 @@ def send_request(self, http_method, url_path, params, headers, data=None): 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 { diff --git a/coinbase/websocket/channels.py b/coinbase/websocket/channels.py index d517af7..492573b 100644 --- a/coinbase/websocket/channels.py +++ b/coinbase/websocket/channels.py @@ -14,223 +14,607 @@ def heartbeats(self, product_ids: List[str]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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]): """ + **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 index 6eeadc8..db432c3 100644 --- a/coinbase/websocket/websocket_base.py +++ b/coinbase/websocket/websocket_base.py @@ -27,18 +27,57 @@ class WSClientException(Exception): - """Exception raised for errors in the WebSocket client.""" + """ + **WSClientException** + ________________________________________ + + ----------------------------------------- + + Exception raised for errors in the WebSocket client. + """ pass class WSClientConnectionClosedException(Exception): - """Exception raised for unexpected closure in the WebSocket client.""" + """ + **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), @@ -89,6 +128,11 @@ def __init__( def open(self): """ + **Open Websocket** + __________________ + + ------------------------ + Open the websocket client connection. """ if not self.loop or self.loop.is_closed(): @@ -101,6 +145,11 @@ def open(self): async def open_async(self): """ + **Open Websocket Async** + ________________________ + + ------------------------ + Open the websocket client connection asynchronously. """ self._ensure_websocket_not_open() @@ -135,6 +184,11 @@ async def open_async(self): def close(self): """ + **Close Websocket** + ___________________ + + ------------------------ + Close the websocket client connection. """ if self.loop and not self.loop.is_closed(): @@ -151,6 +205,11 @@ def close(self): async def close_async(self): """ + **Close Websocket Async** + _________________________ + + ------------------------ + Close the websocket client connection asynchronously. """ self._ensure_websocket_open() @@ -171,9 +230,15 @@ async def close_async(self): def subscribe(self, product_ids: List[str], channels: List[str]): """ + **Subscribe** + _____________ + + ------------------------ + Subscribe to a list of channels for a list of product ids. - :param product_ids: product ids to subscribe to - :param channels: channels to subscribe to + + - **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)) @@ -182,9 +247,15 @@ def subscribe(self, product_ids: List[str], channels: List[str]): async def subscribe_async(self, product_ids: List[str], channels: List[str]): """ + **Subscribe Async** + ___________________ + + ------------------------ + Async subscribe to a list of channels for a list of product ids. - :param product_ids: product ids to subscribe to - :param channels: channels to subscribe to + + - **product_ids** - product ids to subscribe to + - **channels** - channels to subscribe to """ self._ensure_websocket_open() for channel in channels: @@ -221,9 +292,15 @@ async def subscribe_async(self, product_ids: List[str], channels: List[str]): def unsubscribe(self, product_ids: List[str], channels: List[str]): """ + **Unsubscribe** + _______________ + + ------------------------ + Unsubscribe to a list of channels for a list of product ids. - :param product_ids: product ids to unsubscribe from - :param channels: channels to unsubscribe from + + - **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( @@ -234,9 +311,15 @@ def unsubscribe(self, product_ids: List[str], channels: List[str]): async def unsubscribe_async(self, product_ids: List[str], channels: List[str]): """ + **Unsubscribe Async** + _____________________ + + ------------------------ + Async unsubscribe to a list of channels for a list of product ids. - :param product_ids: product ids to unsubscribe from - :param channels: channels to unsubscribe from + + - **product_ids** - product ids to unsubscribe from + - **channels** - channels to unsubscribe from """ self._ensure_websocket_open() for channel in channels: @@ -273,6 +356,11 @@ async def unsubscribe_async(self, product_ids: List[str], channels: List[str]): def unsubscribe_all(self): """ + **Unsubscribe All** + ________________________ + + ------------------------ + Unsubscribe from all channels you are currently subscribed to. """ if self.loop and not self.loop.is_closed(): @@ -282,6 +370,11 @@ def unsubscribe_all(self): async def unsubscribe_all_async(self): """ + **Unsubscribe All Async** + _________________________ + + ------------------------ + Async unsubscribe from all channels you are currently subscribed to. """ for channel, product_ids in self.subscriptions.items(): @@ -289,21 +382,39 @@ async def unsubscribe_all_async(self): def sleep_with_exception_check(self, sleep: int): """ + **Sleep with Exception Check** + ______________________________ + + ------------------------ + Sleep for a specified number of seconds and check for background exceptions. - :param sleep: number of seconds to sleep. + + - **sleep** - number of seconds to sleep. """ time.sleep(sleep) self.raise_background_exception() async def sleep_with_exception_check_async(self, sleep: int): """ + **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): """ + **Run Forever with Exception Check** + ____________________________________ + + ------------------------ + Runs an endless loop, checking for background exceptions every second. """ while True: @@ -312,6 +423,11 @@ def run_forever_with_exception_check(self): async def run_forever_with_exception_check_async(self): """ + **Run Forever with Exception Check Async** + __________________________________________ + + ------------------------ + Async runs an endless loop, checking for background exceptions every second. """ while True: @@ -320,6 +436,11 @@ async def run_forever_with_exception_check_async(self): def raise_background_exception(self): """ + **Raise Background Exception** + ______________________________ + + ------------------------ + Raise any background exceptions that occurred in the message handler. """ if self._background_exception: @@ -328,17 +449,29 @@ def raise_background_exception(self): 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( @@ -362,6 +495,9 @@ async def _retry_connect_and_resubscribe(): return await _retry_connect_and_resubscribe() async def _message_handler(self): + """ + :meta private: + """ self.handler_open = True while self._is_websocket_open(): try: @@ -411,6 +547,9 @@ async def _message_handler(self): def _build_subscription_message( self, product_ids: List[str], channel: str, message_type: str ): + """ + :meta private: + """ return { "type": message_type, "product_ids": product_ids, @@ -420,14 +559,23 @@ def _build_subscription_message( } 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: 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 index fcb2c00..f2dda06 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,5 @@ -requests==2.31.0 -black==23.3.0 -isort==5.12.0 -cryptography==41.0.6 -PyJWT==2.8.0 -websockets==12.0 -backoff==2.2.1 \ No newline at end of file +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 index 77cc0e2..6e9c828 100644 --- a/setup.py +++ b/setup.py @@ -10,6 +10,9 @@ 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 = {} @@ -31,6 +34,7 @@ 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", diff --git a/tests/rest/test_orders.py b/tests/rest/test_orders.py index 8cbce28..7932390 100644 --- a/tests/rest/test_orders.py +++ b/tests/rest/test_orders.py @@ -444,7 +444,6 @@ def test_stop_limit_order_gtc_buy(self): captured_json = captured_request.json() self.assertEqual(captured_request.query, "") - self.assertEqual(captured_request.path, "/api/v3/brokerage/orders") self.assertEqual( captured_json, { @@ -768,3 +767,638 @@ def test_cancel_orders(self): 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) From f72bf34bbb9678ceb3cac38c690f7f0b42520b73 Mon Sep 17 00:00:00 2001 From: davidMkCb <159589283+davidMkCb@users.noreply.github.com> Date: Tue, 13 Feb 2024 11:43:58 -0800 Subject: [PATCH 20/21] Release v1.1.3 (#28) --- CHANGELOG.md | 5 ++ README.md | 2 +- coinbase/__version__.py | 2 +- coinbase/jwt_generator.py | 8 +-- coinbase/rest/accounts.py | 6 +-- coinbase/rest/common.py | 4 +- coinbase/rest/convert.py | 8 +-- coinbase/rest/fees.py | 4 +- coinbase/rest/futures.py | 14 ++--- coinbase/rest/market_data.py | 6 +-- coinbase/rest/orders.py | 78 ++++++++++++++-------------- coinbase/rest/perpetuals.py | 12 +++-- coinbase/rest/portfolios.py | 16 +++--- coinbase/rest/products.py | 14 +++-- coinbase/rest/rest_base.py | 10 ++-- coinbase/websocket/channels.py | 64 +++++++++++------------ coinbase/websocket/websocket_base.py | 34 ++++++------ 17 files changed, 154 insertions(+), 133 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bdd750..c3411c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [1.1.3] - 2024-FEB-13 + +### Added +- Full MyPy annotations with return types for function definitions + ## [1.1.2] - 2024-FEB-9 ### Added diff --git a/README.md b/README.md index 729c4a5..3ef3e23 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 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/ +For thorough documentation of all available functions, refer to the following link: https://coinbase.github.io/coinbase-advanced-py ## Installation diff --git a/coinbase/__version__.py b/coinbase/__version__.py index 72f26f5..0b2f79d 100644 --- a/coinbase/__version__.py +++ b/coinbase/__version__.py @@ -1 +1 @@ -__version__ = "1.1.2" +__version__ = "1.1.3" diff --git a/coinbase/jwt_generator.py b/coinbase/jwt_generator.py index 0517d6f..7ede411 100644 --- a/coinbase/jwt_generator.py +++ b/coinbase/jwt_generator.py @@ -7,7 +7,7 @@ from coinbase.constants import BASE_URL, REST_SERVICE, WS_SERVICE -def build_jwt(key_var, secret_var, service, uri=None): +def build_jwt(key_var, secret_var, service, uri=None) -> str: """ :meta private: """ @@ -44,7 +44,7 @@ def build_jwt(key_var, secret_var, service, uri=None): return jwt_token -def build_rest_jwt(uri, key_var, secret_var): +def build_rest_jwt(uri, key_var, secret_var) -> str: """ **Build REST JWT** __________ @@ -64,7 +64,7 @@ def build_rest_jwt(uri, key_var, secret_var): return build_jwt(key_var, secret_var, REST_SERVICE, uri=uri) -def build_ws_jwt(key_var, secret_var): +def build_ws_jwt(key_var, secret_var) -> str: """ **Build WebSocket JWT** __________ @@ -83,7 +83,7 @@ def build_ws_jwt(key_var, secret_var): return build_jwt(key_var, secret_var, WS_SERVICE) -def format_jwt_uri(method, path): +def format_jwt_uri(method, path) -> str: """ **Format JWT URI** __________ diff --git a/coinbase/rest/accounts.py b/coinbase/rest/accounts.py index f2f1845..f6b233b 100644 --- a/coinbase/rest/accounts.py +++ b/coinbase/rest/accounts.py @@ -1,11 +1,11 @@ -from typing import Optional +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** _________________ @@ -28,7 +28,7 @@ def get_accounts( return self.get(endpoint, params=params, **kwargs) -def get_account(self, account_uuid: str, **kwargs): +def get_account(self, account_uuid: str, **kwargs) -> Dict[str, Any]: """ **Get Account** diff --git a/coinbase/rest/common.py b/coinbase/rest/common.py index 7976c64..3a4e2a7 100644 --- a/coinbase/rest/common.py +++ b/coinbase/rest/common.py @@ -1,7 +1,9 @@ +from typing import Any, Dict + from coinbase.constants import API_PREFIX -def get_unix_time(self, **kwargs): +def get_unix_time(self, **kwargs) -> Dict[str, Any]: """ **Get UNIX Time** _________________ diff --git a/coinbase/rest/convert.py b/coinbase/rest/convert.py index 581b53f..a3b3ccd 100644 --- a/coinbase/rest/convert.py +++ b/coinbase/rest/convert.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Any, Dict, Optional from coinbase.constants import API_PREFIX @@ -11,7 +11,7 @@ def create_convert_quote( user_incentive_id: Optional[str] = None, code_val: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Create Convert Quote** ________________________ @@ -54,7 +54,7 @@ def create_convert_quote( def get_convert_trade( self, trade_id: str, from_account: str, to_account: str, **kwargs -): +) -> Dict[str, Any]: """ **Get Convert Trade** _____________________ @@ -83,7 +83,7 @@ def get_convert_trade( def commit_convert_trade( self, trade_id: str, from_account: str, to_account: str, **kwargs -): +) -> Dict[str, Any]: """ **Commit Convert Trade** ________________________ diff --git a/coinbase/rest/fees.py b/coinbase/rest/fees.py index b0d3d71..5b65e00 100644 --- a/coinbase/rest/fees.py +++ b/coinbase/rest/fees.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Any, Dict, Optional from coinbase.constants import API_PREFIX @@ -8,7 +8,7 @@ def get_transaction_summary( product_type: Optional[str] = None, contract_expiry_type: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Get Transactions Summary** _____________________________ diff --git a/coinbase/rest/futures.py b/coinbase/rest/futures.py index 039e29a..904fa16 100644 --- a/coinbase/rest/futures.py +++ b/coinbase/rest/futures.py @@ -1,7 +1,9 @@ +from typing import Any, Dict + from coinbase.constants import API_PREFIX -def get_futures_balance_summary(self, **kwargs): +def get_futures_balance_summary(self, **kwargs) -> Dict[str, Any]: """ **Get Futures Balance Summary** _______________________________ @@ -24,7 +26,7 @@ def get_futures_balance_summary(self, **kwargs): return self.get(endpoint, **kwargs) -def list_futures_positions(self, **kwargs): +def list_futures_positions(self, **kwargs) -> Dict[str, Any]: """ **List Futures Positions** __________________________ @@ -47,7 +49,7 @@ def list_futures_positions(self, **kwargs): return self.get(endpoint, **kwargs) -def get_futures_position(self, product_id: str, **kwargs): +def get_futures_position(self, product_id: str, **kwargs) -> Dict[str, Any]: """ **Get Futures Position** _________________________ @@ -70,7 +72,7 @@ def get_futures_position(self, product_id: str, **kwargs): return self.get(endpoint, **kwargs) -def schedule_futures_sweep(self, usd_amount: str, **kwargs): +def schedule_futures_sweep(self, usd_amount: str, **kwargs) -> Dict[str, Any]: """ **Schedule Futures Sweep** __________________________ @@ -95,7 +97,7 @@ def schedule_futures_sweep(self, usd_amount: str, **kwargs): return self.post(endpoint, data=data, **kwargs) -def list_futures_sweeps(self, **kwargs): +def list_futures_sweeps(self, **kwargs) -> Dict[str, Any]: """ **List Futures Sweeps** _______________________ @@ -118,7 +120,7 @@ def list_futures_sweeps(self, **kwargs): return self.get(endpoint, **kwargs) -def cancel_pending_futures_sweep(self, **kwargs): +def cancel_pending_futures_sweep(self, **kwargs) -> Dict[str, Any]: """ **Cancel Pending Futures Sweep** ________________________________ diff --git a/coinbase/rest/market_data.py b/coinbase/rest/market_data.py index 690822f..eef5b9a 100644 --- a/coinbase/rest/market_data.py +++ b/coinbase/rest/market_data.py @@ -1,11 +1,11 @@ -from typing import Optional +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** __________ @@ -41,7 +41,7 @@ def get_market_trades( start: Optional[str] = None, end: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Get Market Trades** _____________________ diff --git a/coinbase/rest/orders.py b/coinbase/rest/orders.py index 05d969d..188f85a 100644 --- a/coinbase/rest/orders.py +++ b/coinbase/rest/orders.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Any, Dict, List, Optional from coinbase.constants import API_PREFIX @@ -14,7 +14,7 @@ def create_order( margin_type: Optional[str] = None, retail_portfolio_id: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Create Order** ________________ @@ -61,7 +61,7 @@ def market_order( margin_type: Optional[str] = None, retail_portfolio_id: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Market Order** ________________ @@ -112,7 +112,7 @@ def market_order_buy( margin_type: Optional[str] = None, retail_portfolio_id: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Create Market Order Buy** ____________________ @@ -154,7 +154,7 @@ def market_order_sell( margin_type: Optional[str] = None, retail_portfolio_id: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Create Market Order Sell** _____________________ @@ -200,7 +200,7 @@ def limit_order_gtc( margin_type: Optional[str] = None, retail_portfolio_id: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Limit Order GTC** ___________________ @@ -253,7 +253,7 @@ def limit_order_gtc_buy( margin_type: Optional[str] = None, retail_portfolio_id: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Limit Order GTC Buy** _______________________ @@ -300,7 +300,7 @@ def limit_order_gtc_sell( margin_type: Optional[str] = None, retail_portfolio_id: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Limit Order GTC Sell** ________________________ @@ -350,7 +350,7 @@ def limit_order_gtd( margin_type: Optional[str] = None, retail_portfolio_id: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Limit Order GTD** ___________________ @@ -405,7 +405,7 @@ def limit_order_gtd_buy( margin_type: Optional[str] = None, retail_portfolio_id: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Limit Order GTD Buy** _______________________ @@ -454,7 +454,7 @@ def limit_order_gtd_sell( margin_type: Optional[str] = None, retail_portfolio_id: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Limit Order GTD Sell** ________________________ @@ -505,7 +505,7 @@ def stop_limit_order_gtc( margin_type: Optional[str] = None, retail_portfolio_id: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Stop-Limit Order GTC** ________________________ @@ -560,7 +560,7 @@ def stop_limit_order_gtc_buy( margin_type: Optional[str] = None, retail_portfolio_id: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Stop-Limit Order GTC Buy** ____________________________ @@ -609,7 +609,7 @@ def stop_limit_order_gtc_sell( margin_type: Optional[str] = None, retail_portfolio_id: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Stop-Limit Order GTC Sell** _____________________________ @@ -661,7 +661,7 @@ def stop_limit_order_gtd( margin_type: Optional[str] = None, retail_portfolio_id: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Stop-Limit Order GTD** ________________________ @@ -718,7 +718,7 @@ def stop_limit_order_gtd_buy( margin_type: Optional[str] = None, retail_portfolio_id: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Stop-Limit Order GTD Buy** ____________________________ @@ -769,7 +769,7 @@ def stop_limit_order_gtd_sell( margin_type: Optional[str] = None, retail_portfolio_id: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Stop-Limit Order GTD Sell** _____________________________ @@ -806,7 +806,7 @@ def stop_limit_order_gtd_sell( ) -def get_order(self, order_id: str, **kwargs): +def get_order(self, order_id: str, **kwargs) -> Dict[str, Any]: """ **Get Order** _____________ @@ -845,7 +845,7 @@ def list_orders( asset_filters: Optional[List[str]] = None, retail_portfolio_id: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **List Orders** _______________ @@ -892,7 +892,7 @@ def get_fills( limit: Optional[int] = None, cursor: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **List Fills** ______________ @@ -929,7 +929,7 @@ def edit_order( size: Optional[str] = None, price: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Edit Order** ______________ @@ -963,7 +963,7 @@ def preview_edit_order( size: Optional[str] = None, price: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Preview Edit Order** ______________________ @@ -991,7 +991,7 @@ def preview_edit_order( return self.post(endpoint, data=data, **kwargs) -def cancel_orders(self, order_ids: List[str], **kwargs): +def cancel_orders(self, order_ids: List[str], **kwargs) -> Dict[str, Any]: """ **Cancel Orders** _________________ @@ -1029,7 +1029,7 @@ def preview_order( leverage: Optional[str] = None, margin_type: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Preview Order** _________________ @@ -1081,7 +1081,7 @@ def preview_market_order( leverage: Optional[str] = None, margin_type: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Preview Market Order** ________________________ @@ -1133,7 +1133,7 @@ def preview_market_order_buy( leverage: Optional[str] = None, margin_type: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Preview Market Buy Order** ____________________________ @@ -1177,7 +1177,7 @@ def preview_market_order_sell( leverage: Optional[str] = None, margin_type: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Preview Market Sell Order** _____________________________ @@ -1225,7 +1225,7 @@ def preview_limit_order_gtc( leverage: Optional[str] = None, margin_type: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Preview Limit Order GTC** ___________________________ @@ -1279,7 +1279,7 @@ def preview_limit_order_gtc_buy( leverage: Optional[str] = None, margin_type: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Preview Limit Order GTC Buy** _______________________________ @@ -1327,7 +1327,7 @@ def preview_limit_order_gtc_sell( leverage: Optional[str] = None, margin_type: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Preview Limit Order GTC Sell** ________________________________ @@ -1378,7 +1378,7 @@ def preview_limit_order_gtd( leverage: Optional[str] = None, margin_type: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Preview Limit Order GTD** ___________________________ @@ -1434,7 +1434,7 @@ def preview_limit_order_gtd_buy( leverage: Optional[str] = None, margin_type: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Preview Limit Order GTD Buy** _______________________________ @@ -1484,7 +1484,7 @@ def preview_limit_order_gtd_sell( leverage: Optional[str] = None, margin_type: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Preview Limit Order GTD Sell** ________________________________ @@ -1536,7 +1536,7 @@ def preview_stop_limit_order_gtc( leverage: Optional[str] = None, margin_type: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Preview Stop-Limit Order GTC** ________________________________ @@ -1592,7 +1592,7 @@ def preview_stop_limit_order_gtc_buy( leverage: Optional[str] = None, margin_type: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Preview Stop-Limit Order GTC Buy** ____________________________________ @@ -1642,7 +1642,7 @@ def preview_stop_limit_order_gtc_sell( leverage: Optional[str] = None, margin_type: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Preview Stop-Limit Order GTC Sell** _____________________________________ @@ -1695,7 +1695,7 @@ def preview_stop_limit_order_gtd( leverage: Optional[str] = None, margin_type: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Preview Stop-Limit Order GTD** ________________________________ @@ -1753,7 +1753,7 @@ def preview_stop_limit_order_gtd_buy( leverage: Optional[str] = None, margin_type: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Preview Stop-Limit Order GTD Buy** ____________________________________ @@ -1805,7 +1805,7 @@ def preview_stop_limit_order_gtd_sell( leverage: Optional[str] = None, margin_type: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **Preview Stop-Limit Order GTD Sell** _____________________________________ diff --git a/coinbase/rest/perpetuals.py b/coinbase/rest/perpetuals.py index c0be6f5..e328fea 100644 --- a/coinbase/rest/perpetuals.py +++ b/coinbase/rest/perpetuals.py @@ -1,11 +1,11 @@ -from typing import Optional +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** ________________ @@ -36,7 +36,7 @@ def allocate_portfolio( return self.post(endpoint, data=data, **kwargs) -def get_perps_portfolio_summary(self, portfolio_uuid: str, **kwargs): +def get_perps_portfolio_summary(self, portfolio_uuid: str, **kwargs) -> Dict[str, Any]: """ **Get Perpetuals Portfolio Summary** ________________ @@ -59,7 +59,7 @@ def get_perps_portfolio_summary(self, portfolio_uuid: str, **kwargs): return self.get(endpoint, **kwargs) -def list_perps_positions(self, portfolio_uuid: str, **kwargs): +def list_perps_positions(self, portfolio_uuid: str, **kwargs) -> Dict[str, Any]: """ **List Perpetuals Positions** ________________ @@ -82,7 +82,9 @@ def list_perps_positions(self, portfolio_uuid: str, **kwargs): return self.get(endpoint, **kwargs) -def get_perps_position(self, portfolio_uuid: str, symbol: str, **kwargs): +def get_perps_position( + self, portfolio_uuid: str, symbol: str, **kwargs +) -> Dict[str, Any]: """ **Get Perpetuals Position** ________________ diff --git a/coinbase/rest/portfolios.py b/coinbase/rest/portfolios.py index ce3aa44..be13b5d 100644 --- a/coinbase/rest/portfolios.py +++ b/coinbase/rest/portfolios.py @@ -1,9 +1,11 @@ -from typing import Optional +from typing import Any, Dict, Optional from coinbase.constants import API_PREFIX -def get_portfolios(self, portfolio_type: Optional[str] = None, **kwargs): +def get_portfolios( + self, portfolio_type: Optional[str] = None, **kwargs +) -> Dict[str, Any]: """ **List Portfolios** ___________________ @@ -28,7 +30,7 @@ def get_portfolios(self, portfolio_type: Optional[str] = None, **kwargs): return self.get(endpoint, params=params, **kwargs) -def create_portfolio(self, name: str, **kwargs): +def create_portfolio(self, name: str, **kwargs) -> Dict[str, Any]: """ **Create Portfolio** ____________________ @@ -55,7 +57,7 @@ def create_portfolio(self, name: str, **kwargs): return self.post(endpoint, data=data, **kwargs) -def get_portfolio_breakdown(self, portfolio_uuid: str, **kwargs): +def get_portfolio_breakdown(self, portfolio_uuid: str, **kwargs) -> Dict[str, Any]: """ **Get Portfolio Breakdown** ___________________________ @@ -85,7 +87,7 @@ def move_portfolio_funds( source_portfolio_uuid: str, target_portfolio_uuid: str, **kwargs, -): +) -> Dict[str, Any]: """ **Move Portfolio Funds** ________________________ @@ -117,7 +119,7 @@ def move_portfolio_funds( return self.post(endpoint, data=data, **kwargs) -def edit_portfolio(self, portfolio_uuid: str, name: str, **kwargs): +def edit_portfolio(self, portfolio_uuid: str, name: str, **kwargs) -> Dict[str, Any]: """ **Edit Portfolio** __________________ @@ -144,7 +146,7 @@ def edit_portfolio(self, portfolio_uuid: str, name: str, **kwargs): return self.put(endpoint, data=data, **kwargs) -def delete_portfolio(self, portfolio_uuid: str, **kwargs): +def delete_portfolio(self, portfolio_uuid: str, **kwargs) -> Dict[str, Any]: """ **Delete Portfolio** ____________________ diff --git a/coinbase/rest/products.py b/coinbase/rest/products.py index 6a850a6..c633ed5 100644 --- a/coinbase/rest/products.py +++ b/coinbase/rest/products.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import Any, Dict, List, Optional from coinbase.constants import API_PREFIX @@ -12,7 +12,7 @@ def get_products( contract_expiry_type: Optional[str] = None, expiring_contract_status: Optional[str] = None, **kwargs, -): +) -> Dict[str, Any]: """ **List Products** _________________ @@ -44,7 +44,7 @@ def get_products( return self.get(endpoint, params=params, **kwargs) -def get_product(self, product_id: str, **kwargs): +def get_product(self, product_id: str, **kwargs) -> Dict[str, Any]: """ **Get Product** _______________ @@ -67,7 +67,9 @@ def get_product(self, product_id: str, **kwargs): return self.get(endpoint, **kwargs) -def get_product_book(self, product_id: str, limit: Optional[int] = None, **kwargs): +def get_product_book( + self, product_id: str, limit: Optional[int] = None, **kwargs +) -> Dict[str, Any]: """ **Get Product Book** ____________________ @@ -92,7 +94,9 @@ def get_product_book(self, product_id: str, limit: Optional[int] = None, **kwarg return self.get(endpoint, params=params, **kwargs) -def get_best_bid_ask(self, product_ids: Optional[List[str]] = None, **kwargs): +def get_best_bid_ask( + self, product_ids: Optional[List[str]] = None, **kwargs +) -> Dict[str, Any]: """ **Get Best Bid/Ask** ____________________ diff --git a/coinbase/rest/rest_base.py b/coinbase/rest/rest_base.py index 3505b69..41cf8fb 100644 --- a/coinbase/rest/rest_base.py +++ b/coinbase/rest/rest_base.py @@ -1,6 +1,6 @@ import logging import os -from typing import IO, Optional, Union +from typing import IO, Any, Dict, Optional, Union import requests from requests.exceptions import HTTPError @@ -81,7 +81,7 @@ def __init__( if verbose: logger.setLevel(logging.DEBUG) - def get(self, url_path, params: Optional[dict] = None, **kwargs): + def get(self, url_path, params: Optional[dict] = None, **kwargs) -> Dict[str, Any]: """ **Authenticated GET Request** _____________________________ @@ -109,7 +109,7 @@ def post( params: Optional[dict] = None, data: Optional[dict] = None, **kwargs, - ): + ) -> Dict[str, Any]: """ **Authenticated POST Request** ______________________________ @@ -135,7 +135,7 @@ def put( params: Optional[dict] = None, data: Optional[dict] = None, **kwargs, - ): + ) -> Dict[str, Any]: """ **Authenticated PUT Request** _____________________________ @@ -161,7 +161,7 @@ def delete( params: Optional[dict] = None, data: Optional[dict] = None, **kwargs, - ): + ) -> Dict[str, Any]: """ **Authenticated DELETE Request** ________________________________ diff --git a/coinbase/websocket/channels.py b/coinbase/websocket/channels.py index 492573b..dfebccf 100644 --- a/coinbase/websocket/channels.py +++ b/coinbase/websocket/channels.py @@ -12,7 +12,7 @@ ) -def heartbeats(self, product_ids: List[str]): +def heartbeats(self, product_ids: List[str]) -> None: """ **Heartbeats Subscribe** ________________________ @@ -31,7 +31,7 @@ def heartbeats(self, product_ids: List[str]): self.subscribe(product_ids, [HEARTBEATS]) -async def heartbeats_async(self, product_ids: List[str]): +async def heartbeats_async(self, product_ids: List[str]) -> None: """ **Heartbeats Subscribe Async** ______________________________ @@ -50,7 +50,7 @@ async def heartbeats_async(self, product_ids: List[str]): await self.subscribe_async(product_ids, [HEARTBEATS]) -def heartbeats_unsubscribe(self, product_ids: List[str]): +def heartbeats_unsubscribe(self, product_ids: List[str]) -> None: """ **Heartbeats Unsubscribe** __________________________ @@ -69,7 +69,7 @@ def heartbeats_unsubscribe(self, product_ids: List[str]): self.unsubscribe(product_ids, [HEARTBEATS]) -async def heartbeats_unsubscribe_async(self, product_ids: List[str]): +async def heartbeats_unsubscribe_async(self, product_ids: List[str]) -> None: """ **Heartbeats Unsubscribe Async** ________________________________ @@ -88,7 +88,7 @@ async def heartbeats_unsubscribe_async(self, product_ids: List[str]): await self.unsubscribe_async(product_ids, [HEARTBEATS]) -def candles(self, product_ids: List[str]): +def candles(self, product_ids: List[str]) -> None: """ **Candles Subscribe** _____________________ @@ -107,7 +107,7 @@ def candles(self, product_ids: List[str]): self.subscribe(product_ids, [CANDLES]) -async def candles_async(self, product_ids: List[str]): +async def candles_async(self, product_ids: List[str]) -> None: """ **Candles Subscribe Async** ___________________________ @@ -126,7 +126,7 @@ async def candles_async(self, product_ids: List[str]): await self.subscribe_async(product_ids, [CANDLES]) -def candles_unsubscribe(self, product_ids: List[str]): +def candles_unsubscribe(self, product_ids: List[str]) -> None: """ **Candles Unsubscribe** _______________________ @@ -145,7 +145,7 @@ def candles_unsubscribe(self, product_ids: List[str]): self.unsubscribe(product_ids, [CANDLES]) -async def candles_unsubscribe_async(self, product_ids: List[str]): +async def candles_unsubscribe_async(self, product_ids: List[str]) -> None: """ **Candles Unsubscribe Async** _____________________________ @@ -164,7 +164,7 @@ async def candles_unsubscribe_async(self, product_ids: List[str]): await self.unsubscribe_async(product_ids, [CANDLES]) -def market_trades(self, product_ids: List[str]): +def market_trades(self, product_ids: List[str]) -> None: """ **Market Trades Subscribe** ___________________________ @@ -183,7 +183,7 @@ def market_trades(self, product_ids: List[str]): self.subscribe(product_ids, [MARKET_TRADES]) -async def market_trades_async(self, product_ids: List[str]): +async def market_trades_async(self, product_ids: List[str]) -> None: """ **Market Trades Subscribe Async** _________________________________ @@ -202,7 +202,7 @@ async def market_trades_async(self, product_ids: List[str]): await self.subscribe_async(product_ids, [MARKET_TRADES]) -def market_trades_unsubscribe(self, product_ids: List[str]): +def market_trades_unsubscribe(self, product_ids: List[str]) -> None: """ **Market Trades Unsubscribe** _____________________________ @@ -221,7 +221,7 @@ def market_trades_unsubscribe(self, product_ids: List[str]): self.unsubscribe(product_ids, [MARKET_TRADES]) -async def market_trades_unsubscribe_async(self, product_ids: List[str]): +async def market_trades_unsubscribe_async(self, product_ids: List[str]) -> None: """ **Market Trades Unsubscribe Async** ___________________________________ @@ -240,7 +240,7 @@ async def market_trades_unsubscribe_async(self, product_ids: List[str]): await self.unsubscribe_async(product_ids, [MARKET_TRADES]) -def status(self, product_ids: List[str]): +def status(self, product_ids: List[str]) -> None: """ **Status Subscribe** ____________________ @@ -259,7 +259,7 @@ def status(self, product_ids: List[str]): self.subscribe(product_ids, [STATUS]) -async def status_async(self, product_ids: List[str]): +async def status_async(self, product_ids: List[str]) -> None: """ **Status Subscribe Async** __________________________ @@ -278,7 +278,7 @@ async def status_async(self, product_ids: List[str]): await self.subscribe_async(product_ids, [STATUS]) -def status_unsubscribe(self, product_ids: List[str]): +def status_unsubscribe(self, product_ids: List[str]) -> None: """ **Status Unsubscribe** ______________________ @@ -297,7 +297,7 @@ def status_unsubscribe(self, product_ids: List[str]): self.unsubscribe(product_ids, [STATUS]) -async def status_unsubscribe_async(self, product_ids: List[str]): +async def status_unsubscribe_async(self, product_ids: List[str]) -> None: """ **Status Unsubscribe Async** ____________________________ @@ -316,7 +316,7 @@ async def status_unsubscribe_async(self, product_ids: List[str]): await self.unsubscribe_async(product_ids, [STATUS]) -def ticker(self, product_ids: List[str]): +def ticker(self, product_ids: List[str]) -> None: """ **Ticker Subscribe** ____________________ @@ -335,7 +335,7 @@ def ticker(self, product_ids: List[str]): self.subscribe(product_ids, [TICKER]) -async def ticker_async(self, product_ids: List[str]): +async def ticker_async(self, product_ids: List[str]) -> None: """ **Ticker Subscribe Async** __________________________ @@ -354,7 +354,7 @@ async def ticker_async(self, product_ids: List[str]): await self.subscribe_async(product_ids, [TICKER]) -def ticker_unsubscribe(self, product_ids: List[str]): +def ticker_unsubscribe(self, product_ids: List[str]) -> None: """ **Ticker Unsubscribe** ______________________ @@ -373,7 +373,7 @@ def ticker_unsubscribe(self, product_ids: List[str]): self.unsubscribe(product_ids, [TICKER]) -async def ticker_unsubscribe_async(self, product_ids: List[str]): +async def ticker_unsubscribe_async(self, product_ids: List[str]) -> None: """ **Ticker Unsubscribe Async** ____________________________ @@ -392,7 +392,7 @@ async def ticker_unsubscribe_async(self, product_ids: List[str]): await self.unsubscribe_async(product_ids, [TICKER]) -def ticker_batch(self, product_ids: List[str]): +def ticker_batch(self, product_ids: List[str]) -> None: """ **Ticker Batch Subscribe** __________________________ @@ -411,7 +411,7 @@ def ticker_batch(self, product_ids: List[str]): self.subscribe(product_ids, [TICKER_BATCH]) -async def ticker_batch_async(self, product_ids: List[str]): +async def ticker_batch_async(self, product_ids: List[str]) -> None: """ **Ticker Batch Subscribe Async** ________________________________ @@ -430,7 +430,7 @@ async def ticker_batch_async(self, product_ids: List[str]): await self.subscribe_async(product_ids, [TICKER_BATCH]) -def ticker_batch_unsubscribe(self, product_ids: List[str]): +def ticker_batch_unsubscribe(self, product_ids: List[str]) -> None: """ **Ticker Batch Unsubscribe** ____________________________ @@ -449,7 +449,7 @@ def ticker_batch_unsubscribe(self, product_ids: List[str]): self.unsubscribe(product_ids, [TICKER_BATCH]) -async def ticker_batch_unsubscribe_async(self, product_ids: List[str]): +async def ticker_batch_unsubscribe_async(self, product_ids: List[str]) -> None: """ **Ticker Batch Unsubscribe Async** __________________________________ @@ -468,7 +468,7 @@ async def ticker_batch_unsubscribe_async(self, product_ids: List[str]): await self.unsubscribe_async(product_ids, [TICKER_BATCH]) -def level2(self, product_ids: List[str]): +def level2(self, product_ids: List[str]) -> None: """ **Level2 Subscribe** ____________________ @@ -487,7 +487,7 @@ def level2(self, product_ids: List[str]): self.subscribe(product_ids, [LEVEL2]) -async def level2_async(self, product_ids: List[str]): +async def level2_async(self, product_ids: List[str]) -> None: """ **Level2 Subscribe Async** __________________________ @@ -506,7 +506,7 @@ async def level2_async(self, product_ids: List[str]): await self.subscribe_async(product_ids, [LEVEL2]) -def level2_unsubscribe(self, product_ids: List[str]): +def level2_unsubscribe(self, product_ids: List[str]) -> None: """ **Level2 Unsubscribe** ______________________ @@ -525,7 +525,7 @@ def level2_unsubscribe(self, product_ids: List[str]): self.unsubscribe(product_ids, [LEVEL2]) -async def level2_unsubscribe_async(self, product_ids: List[str]): +async def level2_unsubscribe_async(self, product_ids: List[str]) -> None: """ **Level2 Unsubscribe Async** ____________________________ @@ -544,7 +544,7 @@ async def level2_unsubscribe_async(self, product_ids: List[str]): await self.unsubscribe_async(product_ids, [LEVEL2]) -def user(self, product_ids: List[str]): +def user(self, product_ids: List[str]) -> None: """ **User Subscribe** __________________ @@ -563,7 +563,7 @@ def user(self, product_ids: List[str]): self.subscribe(product_ids, [USER]) -async def user_async(self, product_ids: List[str]): +async def user_async(self, product_ids: List[str]) -> None: """ **User Subscribe Async** ________________________ @@ -582,7 +582,7 @@ async def user_async(self, product_ids: List[str]): await self.subscribe_async(product_ids, [USER]) -def user_unsubscribe(self, product_ids: List[str]): +def user_unsubscribe(self, product_ids: List[str]) -> None: """ **User Unsubscribe** ____________________ @@ -601,7 +601,7 @@ def user_unsubscribe(self, product_ids: List[str]): self.unsubscribe(product_ids, [USER]) -async def user_unsubscribe_async(self, product_ids: List[str]): +async def user_unsubscribe_async(self, product_ids: List[str]) -> None: """ **User Unsubscribe Async** __________________________ diff --git a/coinbase/websocket/websocket_base.py b/coinbase/websocket/websocket_base.py index db432c3..8326551 100644 --- a/coinbase/websocket/websocket_base.py +++ b/coinbase/websocket/websocket_base.py @@ -126,7 +126,7 @@ def __init__( self._background_exception = None self._retrying = False - def open(self): + def open(self) -> None: """ **Open Websocket** __________________ @@ -143,7 +143,7 @@ def open(self): self._run_coroutine_threadsafe(self.open_async()) - async def open_async(self): + async def open_async(self) -> None: """ **Open Websocket Async** ________________________ @@ -182,7 +182,7 @@ async def open_async(self): logger.error("Failed to establish WebSocket connection: %s", wse) raise WSClientException("Failed to establish WebSocket connection") from wse - def close(self): + def close(self) -> None: """ **Close Websocket** ___________________ @@ -203,7 +203,7 @@ def close(self): else: raise WSClientException("Event loop is not running.") - async def close_async(self): + async def close_async(self) -> None: """ **Close Websocket Async** _________________________ @@ -228,7 +228,7 @@ async def close_async(self): 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]): + def subscribe(self, product_ids: List[str], channels: List[str]) -> None: """ **Subscribe** _____________ @@ -245,7 +245,9 @@ def subscribe(self, product_ids: List[str], channels: List[str]): else: raise WSClientException("Websocket Client is not open.") - async def subscribe_async(self, product_ids: List[str], channels: List[str]): + async def subscribe_async( + self, product_ids: List[str], channels: List[str] + ) -> None: """ **Subscribe Async** ___________________ @@ -290,7 +292,7 @@ async def subscribe_async(self, product_ids: List[str], channels: List[str]): f"Failed to subscribe to {channel} channel for product ids {product_ids}." ) from wse - def unsubscribe(self, product_ids: List[str], channels: List[str]): + def unsubscribe(self, product_ids: List[str], channels: List[str]) -> None: """ **Unsubscribe** _______________ @@ -309,7 +311,9 @@ def unsubscribe(self, product_ids: List[str], channels: List[str]): else: raise WSClientException("Websocket Client is not open.") - async def unsubscribe_async(self, product_ids: List[str], channels: List[str]): + async def unsubscribe_async( + self, product_ids: List[str], channels: List[str] + ) -> None: """ **Unsubscribe Async** _____________________ @@ -354,7 +358,7 @@ async def unsubscribe_async(self, product_ids: List[str], channels: List[str]): f"Failed to unsubscribe to {channel} channel for product ids {product_ids}." ) from wse - def unsubscribe_all(self): + def unsubscribe_all(self) -> None: """ **Unsubscribe All** ________________________ @@ -368,7 +372,7 @@ def unsubscribe_all(self): else: raise WSClientException("Websocket Client is not open.") - async def unsubscribe_all_async(self): + async def unsubscribe_all_async(self) -> None: """ **Unsubscribe All Async** _________________________ @@ -380,7 +384,7 @@ async def unsubscribe_all_async(self): for channel, product_ids in self.subscriptions.items(): await self.unsubscribe_async(list(product_ids), [channel]) - def sleep_with_exception_check(self, sleep: int): + def sleep_with_exception_check(self, sleep: int) -> None: """ **Sleep with Exception Check** ______________________________ @@ -394,7 +398,7 @@ def sleep_with_exception_check(self, sleep: int): time.sleep(sleep) self.raise_background_exception() - async def sleep_with_exception_check_async(self, sleep: int): + async def sleep_with_exception_check_async(self, sleep: int) -> None: """ **Sleep with Exception Check Async** ____________________________________ @@ -408,7 +412,7 @@ async def sleep_with_exception_check_async(self, sleep: int): await asyncio.sleep(sleep) self.raise_background_exception() - def run_forever_with_exception_check(self): + def run_forever_with_exception_check(self) -> None: """ **Run Forever with Exception Check** ____________________________________ @@ -421,7 +425,7 @@ def run_forever_with_exception_check(self): time.sleep(1) self.raise_background_exception() - async def run_forever_with_exception_check_async(self): + async def run_forever_with_exception_check_async(self) -> None: """ **Run Forever with Exception Check Async** __________________________________________ @@ -434,7 +438,7 @@ async def run_forever_with_exception_check_async(self): await asyncio.sleep(1) self.raise_background_exception() - def raise_background_exception(self): + def raise_background_exception(self) -> None: """ **Raise Background Exception** ______________________________ From 58f62cc43e4ce1a7a7339ac7b8fbf775909e0341 Mon Sep 17 00:00:00 2001 From: davidMkCb <159589283+davidMkCb@users.noreply.github.com> Date: Mon, 11 Mar 2024 14:18:49 -0700 Subject: [PATCH 21/21] v1.2.0 (#35) * Release v1.2.0 --- CHANGELOG.md | 15 +- coinbase/__version__.py | 2 +- coinbase/rest/__init__.py | 7 + coinbase/rest/common.py | 9 +- coinbase/rest/orders.py | 286 +++++++++++++++++++++++++++ coinbase/rest/payments.py | 47 +++++ coinbase/rest/rest_base.py | 28 ++- coinbase/websocket/websocket_base.py | 12 +- docs/coinbase.rest.rst | 8 + docs/conf.py | 2 + pinned_requirements.txt | 2 +- requirements.txt | 2 +- tests/rest/test_common.py | 2 + tests/rest/test_orders.py | 189 ++++++++++++++++++ tests/rest/test_payments.py | 45 +++++ tests/rest/test_rest_base.py | 25 +++ 16 files changed, 661 insertions(+), 20 deletions(-) create mode 100644 coinbase/rest/payments.py create mode 100644 tests/rest/test_payments.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c3411c4..9e67649 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.2.0] - 2024-MAR-11 + +### Added +- Support for limit IOC order types +- Support for payments endpoints + +### Changed +- get_unix_time() no longer requires authentication +- Log message when subscribing or unsubscribing via WSClient + +### Fixed +- Unsubscribe_all() no longer sends message if not subscribed to any channel + ## [1.1.3] - 2024-FEB-13 ### Added @@ -46,4 +59,4 @@ ## [1.0.0] - 2023-DEC-18 ### Added -- Initial release of the Coinbase Advanced Trading API Python SDK \ No newline at end of file +- Initial release of the Coinbase Advanced Trading API Python SDK diff --git a/coinbase/__version__.py b/coinbase/__version__.py index 0b2f79d..c68196d 100644 --- a/coinbase/__version__.py +++ b/coinbase/__version__.py @@ -1 +1 @@ -__version__ = "1.1.3" +__version__ = "1.2.0" diff --git a/coinbase/rest/__init__.py b/coinbase/rest/__init__.py index 3d0757e..eb41bfe 100755 --- a/coinbase/rest/__init__.py +++ b/coinbase/rest/__init__.py @@ -27,6 +27,9 @@ class RESTClient(RESTBase): limit_order_gtd, limit_order_gtd_buy, limit_order_gtd_sell, + limit_order_ioc, + limit_order_ioc_buy, + limit_order_ioc_sell, list_orders, market_order, market_order_buy, @@ -38,6 +41,9 @@ class RESTClient(RESTBase): preview_limit_order_gtd, preview_limit_order_gtd_buy, preview_limit_order_gtd_sell, + preview_limit_order_ioc, + preview_limit_order_ioc_buy, + preview_limit_order_ioc_sell, preview_market_order, preview_market_order_buy, preview_market_order_sell, @@ -55,6 +61,7 @@ class RESTClient(RESTBase): stop_limit_order_gtd_buy, stop_limit_order_gtd_sell, ) + from .payments import get_payment_method, list_payment_methods from .perpetuals import ( allocate_portfolio, get_perps_portfolio_summary, diff --git a/coinbase/rest/common.py b/coinbase/rest/common.py index 3a4e2a7..ed21a1b 100644 --- a/coinbase/rest/common.py +++ b/coinbase/rest/common.py @@ -5,7 +5,7 @@ def get_unix_time(self, **kwargs) -> Dict[str, Any]: """ - **Get UNIX Time** + **Get Server Time** _________________ [GET] https://api.coinbase.com/api/v3/brokerage/time @@ -13,12 +13,13 @@ def get_unix_time(self, **kwargs) -> Dict[str, Any]: **Description:** - Get the current time from the Coinbase Advanced API. + Get the current time from the Coinbase Advanced API. This is a public endpoint. __________ - **Read more on the official documentation:** `Get UNIX Time `_ + **Read more on the official documentation:** `Get Server Time `_ """ + endpoint = f"{API_PREFIX}/time" - return self.get(endpoint, **kwargs) + return self.get(endpoint, public=True, **kwargs) diff --git a/coinbase/rest/orders.py b/coinbase/rest/orders.py index 188f85a..33163e7 100644 --- a/coinbase/rest/orders.py +++ b/coinbase/rest/orders.py @@ -186,6 +186,149 @@ def market_order_sell( ) +# Limit IOC Orders +def limit_order_ioc( + self, + client_order_id: str, + product_id: str, + side: str, + base_size: str, + limit_price: 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]: + """ + **Limit IOC Order** + ________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders + + __________ + + **Description:** + + Place a Limit Order with a IOC 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 + `_ + """ + + sor_limit_ioc = {"base_size": base_size, "limit_price": limit_price} + + order_configuration = {"sor_limit_ioc": sor_limit_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 limit_order_ioc_buy( + self, + client_order_id: str, + product_id: str, + base_size: str, + limit_price: 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]: + """ + **Limit IOC Order Buy** + ________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders + + __________ + + **Description:** + + Place a Buy Limit Order with a IOC 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_ioc( + self, + client_order_id, + product_id, + "BUY", + base_size=base_size, + limit_price=limit_price, + self_trade_prevention_id=self_trade_prevention_id, + leverage=leverage, + margin_type=margin_type, + retail_portfolio_id=retail_portfolio_id, + **kwargs, + ) + + +def limit_order_ioc_sell( + self, + client_order_id: str, + product_id: str, + base_size: str, + limit_price: 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]: + """ + **Limit IOC Order Sell** + ________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders + + __________ + + **Description:** + + Place a Sell Limit Order with a IOC 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_ioc( + self, + client_order_id, + product_id, + "SELL", + base_size=base_size, + limit_price=limit_price, + 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, @@ -1210,6 +1353,149 @@ def preview_market_order_sell( ) +# Preview Limit IOC orders +def preview_limit_order_ioc( + self, + product_id: str, + side: str, + base_size: str, + limit_price: 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 Limit Order IOC** + ___________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/preview + + __________ + + **Description:** + + Preview the results of a limit order IOC request before sending. + + __________ + + **Read more on the official documentation:** `Preview Order + `_ + """ + order_configuration = { + "sor_limit_ioc": {"base_size": base_size, "limit_price": limit_price} + } + + 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_ioc_buy( + self, + product_id: str, + base_size: str, + limit_price: 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 Limit Order IOC Buy** + ___________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/preview + + __________ + + **Description:** + + Preview the results of a limit order IOC buy request before sending. + + __________ + + **Read more on the official documentation:** `Preview Order + `_ + """ + return preview_limit_order_ioc( + self, + product_id, + "BUY", + base_size=base_size, + limit_price=limit_price, + 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_ioc_sell( + self, + product_id: str, + base_size: str, + limit_price: 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 Limit Order IOC Sell** + ___________________________ + + [POST] https://api.coinbase.com/api/v3/brokerage/orders/preview + + __________ + + **Description:** + + Preview the results of a limit order IOC sell request before sending. + + __________ + + **Read more on the official documentation:** `Preview Order + `_ + """ + return preview_limit_order_ioc( + self, + product_id, + "SELL", + base_size=base_size, + limit_price=limit_price, + 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, diff --git a/coinbase/rest/payments.py b/coinbase/rest/payments.py new file mode 100644 index 0000000..7dc0f41 --- /dev/null +++ b/coinbase/rest/payments.py @@ -0,0 +1,47 @@ +from typing import Any, Dict + +from coinbase.constants import API_PREFIX + + +def list_payment_methods(self, **kwargs) -> Dict[str, Any]: + """ + **List Payment Methods** + _________________ + [GET] https://api.coinbase.com/api/v3/brokerage/payment_methods + + __________ + + **Description:** + + Get a list of payment methods for the current user. + + __________ + + **Read more on the official documentation:** `List Payment Methods `_ + """ + + endpoint = f"{API_PREFIX}/payment_methods" + + return self.get(endpoint, **kwargs) + + +def get_payment_method(self, payment_method_id: str, **kwargs) -> Dict[str, Any]: + """ + **Get Payment Method** + _________________ + [GET] https://api.coinbase.com/api/v3/brokerage/payment_methods/{payment_method_id} + + __________ + + **Description:** + + Get information about a payment method for the current user. + + __________ + + **Read more on the official documentation:** `Get Payment Method `_ + """ + + endpoint = f"{API_PREFIX}/payment_methods/{payment_method_id}" + + return self.get(endpoint, **kwargs) diff --git a/coinbase/rest/rest_base.py b/coinbase/rest/rest_base.py index 41cf8fb..06691e2 100644 --- a/coinbase/rest/rest_base.py +++ b/coinbase/rest/rest_base.py @@ -81,9 +81,11 @@ def __init__( if verbose: logger.setLevel(logging.DEBUG) - def get(self, url_path, params: Optional[dict] = None, **kwargs) -> Dict[str, Any]: + def get( + self, url_path, params: Optional[dict] = None, public=False, **kwargs + ) -> Dict[str, Any]: """ - **Authenticated GET Request** + **GET Request** _____________________________ __________ @@ -92,6 +94,7 @@ def get(self, url_path, params: Optional[dict] = None, **kwargs) -> Dict[str, An - **url_path | (str)** - the URL path - **params | Optional ([dict])** - the query parameters + - **public | (bool)** - flag indicating whether to treat endpoint as public """ @@ -101,7 +104,9 @@ def get(self, url_path, params: Optional[dict] = None, **kwargs) -> Dict[str, An if kwargs: params.update(kwargs) - return self.prepare_and_send_request("GET", url_path, params, data=None) + return self.prepare_and_send_request( + "GET", url_path, params, data=None, public=public + ) def post( self, @@ -187,11 +192,12 @@ def prepare_and_send_request( url_path, params: Optional[dict] = None, data: Optional[dict] = None, + public=False, ): """ :meta private: """ - headers = self.set_headers(http_method, url_path) + headers = self.set_headers(http_method, url_path, public) if params is not None: params = {key: value for key, value in params.items() if value is not None} @@ -226,14 +232,20 @@ def send_request(self, http_method, url_path, params, headers, data=None): return response.json() - def set_headers(self, method, path): + def set_headers(self, method, path, public): """ :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, + "Content-Type": "application/json", + **( + { + "Authorization": f"Bearer {jwt_generator.build_rest_jwt(uri, self.api_key, self.api_secret)}", + } + if not public + else {} + ), } diff --git a/coinbase/websocket/websocket_base.py b/coinbase/websocket/websocket_base.py index 8326551..e7db666 100644 --- a/coinbase/websocket/websocket_base.py +++ b/coinbase/websocket/websocket_base.py @@ -2,6 +2,7 @@ import json import logging import os +import ssl import threading import time from typing import IO, Callable, List, Optional, Union @@ -164,6 +165,7 @@ async def open_async(self) -> None: max_size=self.max_size, user_agent_header=USER_AGENT, extra_headers=headers, + ssl=ssl.SSLContext() if self.base_url.startswith("wss://") else None, ) logger.debug("Successfully connected to %s", self.base_url) @@ -275,7 +277,7 @@ async def subscribe_async( await self.websocket.send(json_message) - logger.debug("Successfully subscribed") + logger.debug("Successfully sent subscription message.") # add to subscriptions map if channel not in self.subscriptions: @@ -341,7 +343,7 @@ async def unsubscribe_async( await self.websocket.send(json_message) - logger.debug("Successfully unsubscribed") + logger.debug("Successfully sent unsubscribe message.") # remove from subscriptions map if channel in self.subscriptions: @@ -382,7 +384,8 @@ async def unsubscribe_all_async(self) -> None: 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]) + if product_ids: + await self.unsubscribe_async(list(product_ids), [channel]) def sleep_with_exception_check(self, sleep: int) -> None: """ @@ -470,7 +473,8 @@ async def _resubscribe(self): :meta private: """ for channel, product_ids in self.subscriptions.items(): - await self.subscribe_async(list(product_ids), [channel]) + if product_ids: + await self.subscribe_async(list(product_ids), [channel]) async def _retry_connection(self): """ diff --git a/docs/coinbase.rest.rst b/docs/coinbase.rest.rst index d693393..fda02cd 100644 --- a/docs/coinbase.rest.rst +++ b/docs/coinbase.rest.rst @@ -50,6 +50,14 @@ Orders :undoc-members: :show-inheritance: +Payments +------------------------------- + +.. automodule:: coinbase.rest.payments + :members: + :undoc-members: + :show-inheritance: + Portfolios ------------------------------- diff --git a/docs/conf.py b/docs/conf.py index 63a7308..058cc8c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -28,3 +28,5 @@ html_theme = "sphinx_rtd_theme" html_static_path = ["_static"] + +autodoc_member_order = "bysource" diff --git a/pinned_requirements.txt b/pinned_requirements.txt index 59ee7ed..2c0cd88 100644 --- a/pinned_requirements.txt +++ b/pinned_requirements.txt @@ -1,5 +1,5 @@ requests==2.31.0 -cryptography==42.0.0 +cryptography==42.0.4 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 index f2dda06..f1d8a28 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ requests>=2.31.0 -cryptography>=42.0.0 +cryptography>=42.0.4 PyJWT>=2.8.0 websockets>=12.0 backoff>=2.2.1 diff --git a/tests/rest/test_common.py b/tests/rest/test_common.py index b33af4a..f33b6c9 100644 --- a/tests/rest/test_common.py +++ b/tests/rest/test_common.py @@ -22,6 +22,8 @@ def test_get_time(self): time = client.get_unix_time() captured_request = m.request_history[0] + captured_headers = captured_request.headers self.assertEqual(captured_request.query, "") self.assertEqual(time, expected_response) + self.assertNotIn("Authorization", captured_headers) diff --git a/tests/rest/test_orders.py b/tests/rest/test_orders.py index 7932390..265c0e6 100644 --- a/tests/rest/test_orders.py +++ b/tests/rest/test_orders.py @@ -140,6 +140,102 @@ def test_market_order_sell(self): ) self.assertEqual(order, expected_response) + def test_limit_order_ioc(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_ioc( + "client_order_id_1", "product_id_1", "BUY", "1", "100" + ) + + 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": { + "sor_limit_ioc": {"base_size": "1", "limit_price": "100"} + }, + }, + ) + self.assertEqual(order, expected_response) + + def test_limit_order_ioc_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_ioc_buy( + "client_order_id_1", "product_id_1", "1", "100" + ) + + 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": { + "sor_limit_ioc": {"base_size": "1", "limit_price": "100"} + }, + }, + ) + self.assertEqual(order, expected_response) + + def test_limit_order_ioc_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_ioc_sell( + "client_order_id_1", "product_id_1", "1", "100" + ) + + 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": { + "sor_limit_ioc": {"base_size": "1", "limit_price": "100"} + }, + }, + ) + self.assertEqual(order, expected_response) + def test_limit_order_gtc(self): client = RESTClient(TEST_API_KEY, TEST_API_SECRET) @@ -904,6 +1000,99 @@ def test_preview_market_order_sell(self): ) self.assertEqual(preview, expected_response) + def test_preview_limit_order_ioc(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_ioc("product_id_1", "BUY", "1", "100") + + 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": { + "sor_limit_ioc": {"base_size": "1", "limit_price": "100"} + }, + "is_max": False, + "skip_fcm_risk_check": False, + }, + ) + self.assertEqual(preview, expected_response) + + def test_preview_limit_order_ioc_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_ioc_buy("product_id_1", "1", "100") + + 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": { + "sor_limit_ioc": {"base_size": "1", "limit_price": "100"} + }, + "is_max": False, + "skip_fcm_risk_check": False, + }, + ) + self.assertEqual(preview, expected_response) + + def test_preview_limit_order_ioc_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_ioc_sell("product_id_1", "1", "100") + + 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": { + "sor_limit_ioc": {"base_size": "1", "limit_price": "100"} + }, + "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) diff --git a/tests/rest/test_payments.py b/tests/rest/test_payments.py new file mode 100644 index 0000000..df7ce6c --- /dev/null +++ b/tests/rest/test_payments.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 PaymentsTest(unittest.TestCase): + def test_list_payment_methods(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"accounts": [{"uuid": "payment1"}, {"name": "payment2"}]} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/payment_methods", + json=expected_response, + ) + payments = client.list_payment_methods() + + captured_request = m.request_history[0] + + self.assertEqual(captured_request.query, "") + self.assertEqual(payments, expected_response) + + def test_get_payment_method(self): + client = RESTClient(TEST_API_KEY, TEST_API_SECRET) + + expected_response = {"uuid": "payment1"} + + with Mocker() as m: + m.request( + "GET", + "https://api.coinbase.com/api/v3/brokerage/payment_methods/payment1", + json=expected_response, + ) + payment = client.get_payment_method("payment1") + + captured_request = m.request_history[0] + + self.assertEqual(captured_request.query, "") + self.assertEqual(payment, expected_response) diff --git a/tests/rest/test_rest_base.py b/tests/rest/test_rest_base.py index 07dc3d7..e5cf8e4 100644 --- a/tests/rest/test_rest_base.py +++ b/tests/rest/test_rest_base.py @@ -43,6 +43,31 @@ def test_get(self): self.assertEqual(accounts, expected_response) + def test_get_public(self): + client = RESTClient(api_key=TEST_API_KEY, api_secret=TEST_API_SECRET) + + with Mocker() as m: + 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, + ) + + client.get("/api/v3/brokerage/time", public=True) + + captured_request = m.request_history[0] + captured_headers = captured_request.headers + + self.assertNotIn("Authorization", captured_headers) + self.assertTrue("User-Agent" in captured_headers) + self.assertEqual( + captured_headers["User-Agent"], + "coinbase-advanced-py/" + __version__, + ) + def test_post(self): client = RESTClient(api_key=TEST_API_KEY, api_secret=TEST_API_SECRET)