From 9a4071563d711190f7d07d9673e9776699190555 Mon Sep 17 00:00:00 2001 From: Uri Schwartz Date: Thu, 7 Dec 2023 14:01:48 -0500 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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={