diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..84bb5aca --- /dev/null +++ b/.coveragerc @@ -0,0 +1,8 @@ +[run] +branch = True +source = + . +omit = + .tox/* + setup.py + tests/* diff --git a/.gitignore b/.gitignore index 60adeb5b..77160174 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +*.DS_Store .idea *.pyc example.py @@ -7,7 +8,8 @@ dist/ *.rst venv/ *log.txt -gdax/__pycache__/ +cbpro/__pycache__/ .cache/ .coverage tests/__pycache__/ +.pytest_cache diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..dec568c6 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,11 @@ +sudo: false +language: python +# cache package wheels (1 cache per python version) +cache: pip +python: 3.5 + +install: + - pip install .[test] + +script: + - python -m pytest tests/ diff --git a/Dockerfile b/Dockerfile index 10de529b..d6285fd5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,9 +2,9 @@ # # Usage: # * build the image: -# gdax-python$ docker build -t gdax-python . +# coinbasepro-python$ docker build -t coinbasepro-python . # * start the image: -# docker run -it gdax-python +# docker run -it coinbasepro-python # Latest version of ubuntu FROM ubuntu:16.04 @@ -29,11 +29,11 @@ ARG python_version=3.6 RUN conda install -y python=${python_version} && \ pip install --upgrade pip -# Set gdax-python code path -ENV CODE_DIR /code/gdax-python +# Set coinbasepro-python code path +ENV CODE_DIR /code/coinbasepro-python RUN mkdir -p $CODE_DIR COPY . $CODE_DIR RUN cd $CODE_DIR && \ - pip install gdax + pip install cbpro diff --git a/README.md b/README.md index d199167d..456d212e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ -# gdax-python -The Python client for the [GDAX API](https://docs.gdax.com/) (formerly known as -the Coinbase Exchange API) +# coinbasepro-python +[![Build Status](https://travis-ci.org/danpaquin/coinbasepro-python.svg?branch=master)](https://travis-ci.org/danpaquin/coinbasepro-python) + +The Python client for the [Coinbase Pro API](https://docs.pro.coinbase.com/) (formerly known as +the GDAX) ##### Provided under MIT License by Daniel Paquin. *Note: this library may be subtly broken or buggy. The code is released under @@ -18,8 +20,8 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. largest Bitcoin exchanges in the *world*! - Do not worry about handling the nuances of the API with easy-to-use methods for every API endpoint. -- Gain an advantage in the market by getting under the hood of GDAX to learn -what and who is *really* behind every tick. +- Gain an advantage in the market by getting under the hood of CB Pro to learn +what and who is behind every tick. ## Under Development - Test Scripts @@ -29,15 +31,17 @@ what and who is *really* behind every tick. ## Getting Started This README is documentation on the syntax of the python client presented in this repository. See function docstrings for full syntax details. -**This API attempts to present a clean interface to GDAX, but in order to use it -to its full potential, you must familiarize yourself with the official GDAX +**This API attempts to present a clean interface to CB Pro, but in order to use it +to its full potential, you must familiarize yourself with the official CB Pro documentation.** -- https://docs.gdax.com/ +- https://docs.pro.coinbase.com/ - You may manually install the project or use ```pip```: ```python -pip install gdax +pip install cbpro +#or +pip install git+git://github.com/danpaquin/coinbasepro-python.git ``` ### Public Client @@ -45,17 +49,17 @@ Only some endpoints in the API are available to everyone. The public endpoints can be reached using ```PublicClient``` ```python -import gdax -public_client = gdax.PublicClient() +import cbpro +public_client = cbpro.PublicClient() ``` ### PublicClient Methods -- [get_products](https://docs.gdax.com/#get-products) +- [get_products](https://docs.pro.coinbase.com//#get-products) ```python public_client.get_products() ``` -- [get_product_order_book](https://docs.gdax.com/#get-product-order-book) +- [get_product_order_book](https://docs.pro.coinbase.com/#get-product-order-book) ```python # Get the order book at the default level. public_client.get_product_order_book('BTC-USD') @@ -63,36 +67,37 @@ public_client.get_product_order_book('BTC-USD') public_client.get_product_order_book('BTC-USD', level=1) ``` -- [get_product_ticker](https://docs.gdax.com/#get-product-ticker) +- [get_product_ticker](https://docs.pro.coinbase.com/#get-product-ticker) ```python # Get the product ticker for a specific product. public_client.get_product_ticker(product_id='ETH-USD') ``` -- [get_product_trades](https://docs.gdax.com/#get-trades) +- [get_product_trades](https://docs.pro.coinbase.com/#get-trades) (paginated) ```python # Get the product trades for a specific product. +# Returns a generator public_client.get_product_trades(product_id='ETH-USD') ``` -- [get_product_historic_rates](https://docs.gdax.com/#get-historic-rates) +- [get_product_historic_rates](https://docs.pro.coinbase.com/#get-historic-rates) ```python public_client.get_product_historic_rates('ETH-USD') # To include other parameters, see function docstring: public_client.get_product_historic_rates('ETH-USD', granularity=3000) ``` -- [get_product_24hr_stats](https://docs.gdax.com/#get-24hr-stats) +- [get_product_24hr_stats](https://docs.pro.coinbase.com/#get-24hr-stats) ```python public_client.get_product_24hr_stats('ETH-USD') ``` -- [get_currencies](https://docs.gdax.com/#get-currencies) +- [get_currencies](https://docs.pro.coinbase.com/#get-currencies) ```python public_client.get_currencies() ``` -- [get_time](https://docs.gdax.com/#time) +- [get_time](https://docs.pro.coinbase.com/#time) ```python public_client.get_time() ``` @@ -102,93 +107,128 @@ public_client.get_time() Not all API endpoints are available to everyone. Those requiring user authentication can be reached using `AuthenticatedClient`. You must setup API access within your -[account settings](https://www.gdax.com/settings/api). +[account settings](https://pro.coinbase.com/profile/api). The `AuthenticatedClient` inherits all methods from the `PublicClient` class, so you will only need to initialize one if you are planning to integrate both into your script. ```python -import gdax -auth_client = gdax.AuthenticatedClient(key, b64secret, passphrase) - +import cbpro +auth_client = cbpro.AuthenticatedClient(key, b64secret, passphrase) # Use the sandbox API (requires a different set of API access credentials) -auth_client = gdax.AuthenticatedClient(key, b64secret, passphrase, - api_url="https://api-public.sandbox.gdax.com") +auth_client = cbpro.AuthenticatedClient(key, b64secret, passphrase, + api_url="https://api-public.sandbox.pro.coinbase.com") ``` ### Pagination -Some calls are [paginated](https://docs.gdax.com/#pagination), meaning multiple -calls must be made to receive the full set of data. Each page/request is a list -of dict objects that are then appended to a master list, making it easy to -navigate pages (e.g. ```request[0]``` would return the first page of data in the -example below). *This feature is under consideration for redesign. Please -provide feedback if you have issues or suggestions* -```python -request = auth_client.get_fills(limit=100) -request[0] # Page 1 always present -request[1] # Page 2+ present only if the data exists -``` -It should be noted that limit does not behave exactly as the official -documentation specifies. If you request a limit and that limit is met, -additional pages will not be returned. This is to ensure speedy response times -when less data is preferred. +Some calls are [paginated](https://docs.pro.coinbase.com/#pagination), meaning multiple +calls must be made to receive the full set of data. The CB Pro Python API provides +an abstraction for paginated endpoints in the form of generators which provide a +clean interface for iteration but may make multiple HTTP requests behind the +scenes. The pagination options `before`, `after`, and `limit` may be supplied as +keyword arguments if desired, but aren't necessary for typical use cases. +```python +fills_gen = auth_client.get_fills() +# Get all fills (will possibly make multiple HTTP requests) +all_fills = list(fills_gen) +``` +One use case for pagination parameters worth pointing out is retrieving only +new data since the previous request. For the case of `get_fills()`, the +`trade_id` is the parameter used for indexing. By passing +`before=some_trade_id`, only fills more recent than that `trade_id` will be +returned. Note that when using `before`, a maximum of 100 entries will be +returned - this is a limitation of CB Pro. +```python +from itertools import islice +# Get 5 most recent fills +recent_fills = islice(auth_client.get_fills(), 5) +# Only fetch new fills since last call by utilizing `before` parameter. +new_fills = auth_client.get_fills(before=recent_fills[0]['trade_id']) +``` ### AuthenticatedClient Methods -- [get_accounts](https://docs.gdax.com/#list-accounts) +- [get_accounts](https://docs.pro.coinbase.com/#list-accounts) ```python auth_client.get_accounts() ``` -- [get_account](https://docs.gdax.com/#get-an-account) +- [get_account](https://docs.pro.coinbase.com/#get-an-account) ```python auth_client.get_account("7d0f7d8e-dd34-4d9c-a846-06f431c381ba") ``` -- [get_account_history](https://docs.gdax.com/#get-account-history) (paginated) +- [get_account_history](https://docs.pro.coinbase.com/#get-account-history) (paginated) ```python +# Returns generator: auth_client.get_account_history("7d0f7d8e-dd34-4d9c-a846-06f431c381ba") ``` -- [get_account_holds](https://docs.gdax.com/#get-holds) (paginated) +- [get_account_holds](https://docs.pro.coinbase.com/#get-holds) (paginated) ```python +# Returns generator: auth_client.get_account_holds("7d0f7d8e-dd34-4d9c-a846-06f431c381ba") ``` -- [buy & sell](https://docs.gdax.com/#place-a-new-order) +- [buy & sell](https://docs.pro.coinbase.com/#place-a-new-order) ```python # Buy 0.01 BTC @ 100 USD auth_client.buy(price='100.00', #USD size='0.01', #BTC + order_type='limit', product_id='BTC-USD') ``` ```python # Sell 0.01 BTC @ 200 USD auth_client.sell(price='200.00', #USD size='0.01', #BTC + order_type='limit', product_id='BTC-USD') ``` +```python +# Limit order-specific method +auth_client.place_limit_order(product_id='BTC-USD', + side='buy', + price='200.00', + size='0.01') +``` +```python +# Place a market order by specifying amount of USD to use. +# Alternatively, `size` could be used to specify quantity in BTC amount. +auth_client.place_market_order(product_id='BTC-USD', + side='buy', + funds='100.00') +``` +```python +# Stop order. `funds` can be used instead of `size` here. +auth_client.place_stop_order(product_id='BTC-USD', + stop_type='loss', + price='200.00', + size='0.01') +``` -- [cancel_order](https://docs.gdax.com/#cancel-an-order) +- [cancel_order](https://docs.pro.coinbase.com/#cancel-an-order) ```python auth_client.cancel_order("d50ec984-77a8-460a-b958-66f114b0de9b") ``` -- [cancel_all](https://docs.gdax.com/#cancel-all) +- [cancel_all](https://docs.pro.coinbase.com/#cancel-all) ```python auth_client.cancel_all(product_id='BTC-USD') ``` -- [get_orders](https://docs.gdax.com/#list-orders) (paginated) +- [get_orders](https://docs.pro.coinbase.com/#list-orders) (paginated) ```python +# Returns generator: auth_client.get_orders() ``` -- [get_order](https://docs.gdax.com/#get-an-order) +- [get_order](https://docs.pro.coinbase.com/#get-an-order) ```python auth_client.get_order("d50ec984-77a8-460a-b958-66f114b0de9b") ``` -- [get_fills](https://docs.gdax.com/#list-fills) (paginated) +- [get_fills](https://docs.pro.coinbase.com/#list-fills) (paginated) ```python +# All return generators auth_client.get_fills() # Get fills for a specific order auth_client.get_fills(order_id="d50ec984-77a8-460a-b958-66f114b0de9b") @@ -196,9 +236,8 @@ auth_client.get_fills(order_id="d50ec984-77a8-460a-b958-66f114b0de9b") auth_client.get_fills(product_id="ETH-BTC") ``` -- [deposit & withdraw](https://docs.gdax.com/#depositwithdraw) +- [deposit & withdraw](https://docs.pro.coinbase.com/#depositwithdraw) ```python -gdax depositParams = { 'amount': '25.00', # Currency determined by account specified 'coinbase_account_id': '60680c98bfe96c2601f27e9c' @@ -206,7 +245,7 @@ depositParams = { auth_client.deposit(depositParams) ``` ```python -# Withdraw from GDAX into Coinbase Wallet +# Withdraw from CB Pro into Coinbase Wallet withdrawParams = { 'amount': '1.00', # Currency determined by account specified 'coinbase_account_id': '536a541fa9393bb3c7000023' @@ -216,23 +255,27 @@ auth_client.withdraw(withdrawParams) ### WebsocketClient If you would like to receive real-time market updates, you must subscribe to the -[websocket feed](https://docs.gdax.com/#websocket-feed). +[websocket feed](https://docs.pro.coinbase.com/#websocket-feed). #### Subscribe to a single product ```python -import gdax -# Paramters are optional -wsClient = gdax.WebsocketClient(url="wss://ws-feed.gdax.com", products="BTC-USD") +import cbpro + +# Parameters are optional +wsClient = cbpro.WebsocketClient(url="wss://ws-feed.pro.coinbase.com", + products="BTC-USD", + channels=["ticker"]) # Do other stuff... wsClient.close() ``` #### Subscribe to multiple products ```python -import gdax -# Paramaters are optional -wsClient = gdax.WebsocketClient(url="wss://ws-feed.gdax.com", - products=["BTC-USD", "ETH-USD"]) +import cbpro +# Parameters are optional +wsClient = cbpro.WebsocketClient(url="wss://ws-feed.pro.coinbase.com", + products=["BTC-USD", "ETH-USD"], + channels=["ticker"]) # Do other stuff... wsClient.close() ``` @@ -244,7 +287,7 @@ the database collection. ```python # import PyMongo and connect to a local, running Mongo instance from pymongo import MongoClient -import gdax +import cbpro mongo_client = MongoClient('mongodb://localhost:27017/') # specify the database and collection @@ -252,7 +295,7 @@ db = mongo_client.cryptocurrency_database BTC_collection = db.BTC_collection # instantiate a WebsocketClient instance, with a Mongo collection as a parameter -wsClient = gdax.WebsocketClient(url="wss://ws-feed.gdax.com", products="BTC-USD", +wsClient = cbpro.WebsocketClient(url="wss://ws-feed.pro.coinbase.com", products="BTC-USD", mongo_collection=BTC_collection, should_print=False) wsClient.start() ``` @@ -263,18 +306,17 @@ There are three methods which you could overwrite (before initialization) so it can react to the data streaming in. The current client is a template used for illustration purposes only. - - onOpen - called once, *immediately before* the socket connection is made, this is where you want to add initial parameters. - onMessage - called once for every message that arrives and accepts one argument that contains the message of dict type. -- onClose - called once after the websocket has been closed. +- on_close - called once after the websocket has been closed. - close - call this method to close the websocket connection (do not overwrite). ```python -import gdax, time -class myWebsocketClient(gdax.WebsocketClient): +import cbpro, time +class myWebsocketClient(cbpro.WebsocketClient): def on_open(self): - self.url = "wss://ws-feed.gdax.com/" + self.url = "wss://ws-feed.pro.coinbase.com/" self.products = ["LTC-USD"] self.message_count = 0 print("Lets count the messages!") @@ -294,29 +336,57 @@ while (wsClient.message_count < 500): time.sleep(1) wsClient.close() ``` - ## Testing -A test suite is under development. To run the tests, start in the project -directory and run +A test suite is under development. Tests for the authenticated client require a +set of sandbox API credentials. To provide them, rename +`api_config.json.example` in the tests folder to `api_config.json` and edit the +file accordingly. To run the tests, start in the project directory and run ``` python -m pytest ``` ### Real-time OrderBook -The ```OrderBook``` subscribes to a websocket and keeps a real-time record of -the orderbook for the product_id input. Please provide your feedback for future +The ```OrderBook``` is a convenient data structure to keep a real-time record of +the orderbook for the product_id input. It processes incoming messages from an +already existing WebsocketClient. Please provide your feedback for future improvements. ```python -import gdax, time -order_book = gdax.OrderBook(product_id='BTC-USD') -order_book.start() +import cbpro, time, Queue +class myWebsocketClient(cbpro.WebsocketClient): + def on_open(self): + self.products = ['BTC-USD', 'ETH-USD'] + self.order_book_btc = OrderBookConsole(product_id='BTC-USD') + self.order_book_eth = OrderBookConsole(product_id='ETH-USD') + def on_message(self, msg): + self.order_book_btc.process_message(msg) + self.order_book_eth.process_message(msg) + +wsClient = myWebsocketClient() +wsClient.start() time.sleep(10) -order_book.close() +while True: + print(wsClient.order_book_btc.get_ask()) + print(wsClient.order_book_eth.get_bid()) + time.sleep(1) +``` + +### Testing +Unit tests are under development using the pytest framework. Contributions are +welcome! + +To run the full test suite, in the project +directory run: +```bash +python -m pytest ``` ## Change Log -*1.0* **Current PyPI release** +*1.1.2* **Current PyPI release** +- Refactor project for Coinbase Pro +- Major overhaul on how pagination is handled + +*1.0* - The first release that is not backwards compatible - Refactored to follow PEP 8 Standards - Improved Documentation diff --git a/gdax/.gitignore b/cbpro/.gitignore similarity index 100% rename from gdax/.gitignore rename to cbpro/.gitignore diff --git a/cbpro/__init__.py b/cbpro/__init__.py new file mode 100644 index 00000000..00937f59 --- /dev/null +++ b/cbpro/__init__.py @@ -0,0 +1,5 @@ +from cbpro.authenticated_client import AuthenticatedClient +from cbpro.public_client import PublicClient +from cbpro.websocket_client import WebsocketClient +from cbpro.order_book import OrderBook +from cbpro.cbpro_auth import CBProAuth diff --git a/cbpro/authenticated_client.py b/cbpro/authenticated_client.py new file mode 100644 index 00000000..f330377f --- /dev/null +++ b/cbpro/authenticated_client.py @@ -0,0 +1,1049 @@ +# +# cbpro/AuthenticatedClient.py +# Daniel Paquin +# +# For authenticated requests to the Coinbase exchange + +import hmac +import hashlib +import time +import requests +import base64 +import json +from requests.auth import AuthBase +from cbpro.public_client import PublicClient +from cbpro.cbpro_auth import CBProAuth + + +class AuthenticatedClient(PublicClient): + """ Provides access to Private Endpoints on the cbpro API. + + All requests default to the live `api_url`: 'https://api.pro.coinbase.com'. + To test your application using the sandbox modify the `api_url`. + + Attributes: + url (str): The api url for this client instance to use. + auth (CBProAuth): Custom authentication handler for each request. + session (requests.Session): Persistent HTTP connection object. + """ + def __init__(self, key, b64secret, passphrase, + api_url="https://api.pro.coinbase.com"): + """ Create an instance of the AuthenticatedClient class. + + Args: + key (str): Your API key. + b64secret (str): The secret key matching your API key. + passphrase (str): Passphrase chosen when setting up key. + api_url (Optional[str]): API URL. Defaults to cbpro API. + """ + super(AuthenticatedClient, self).__init__(api_url) + self.auth = CBProAuth(key, b64secret, passphrase) + self.session = requests.Session() + + def get_account(self, account_id): + """ Get information for a single account. + + Use this endpoint when you know the account_id. + + Args: + account_id (str): Account id for account you want to get. + + Returns: + dict: Account information. Example:: + { + "id": "a1b2c3d4", + "balance": "1.100", + "holds": "0.100", + "available": "1.00", + "currency": "USD" + } + """ + return self._send_message('get', '/accounts/' + account_id) + + def get_accounts(self): + """ Get a list of trading all accounts. + + When you place an order, the funds for the order are placed on + hold. They cannot be used for other orders or withdrawn. Funds + will remain on hold until the order is filled or canceled. The + funds on hold for each account will be specified. + + Returns: + list: Info about all accounts. Example:: + [ + { + "id": "71452118-efc7-4cc4-8780-a5e22d4baa53", + "currency": "BTC", + "balance": "0.0000000000000000", + "available": "0.0000000000000000", + "hold": "0.0000000000000000", + "profile_id": "75da88c5-05bf-4f54-bc85-5c775bd68254" + }, + { + ... + } + ] + + * Additional info included in response for margin accounts. + """ + return self.get_account('') + + def get_account_history(self, account_id, **kwargs): + """ List account activity. Account activity either increases or + decreases your account balance. + + Entry type indicates the reason for the account change. + * transfer: Funds moved to/from Coinbase to cbpro + * match: Funds moved as a result of a trade + * fee: Fee as a result of a trade + * rebate: Fee rebate as per our fee schedule + + If an entry is the result of a trade (match, fee), the details + field will contain additional information about the trade. + + Args: + account_id (str): Account id to get history of. + kwargs (dict): Additional HTTP request parameters. + + Returns: + list: History information for the account. Example:: + [ + { + "id": "100", + "created_at": "2014-11-07T08:19:27.028459Z", + "amount": "0.001", + "balance": "239.669", + "type": "fee", + "details": { + "order_id": "d50ec984-77a8-460a-b958-66f114b0de9b", + "trade_id": "74", + "product_id": "BTC-USD" + } + }, + { + ... + } + ] + """ + endpoint = '/accounts/{}/ledger'.format(account_id) + return self._send_paginated_message(endpoint, params=kwargs) + + def get_account_holds(self, account_id, **kwargs): + """ Get holds on an account. + + This method returns a generator which may make multiple HTTP requests + while iterating through it. + + Holds are placed on an account for active orders or + pending withdraw requests. + + As an order is filled, the hold amount is updated. If an order + is canceled, any remaining hold is removed. For a withdraw, once + it is completed, the hold is removed. + + The `type` field will indicate why the hold exists. The hold + type is 'order' for holds related to open orders and 'transfer' + for holds related to a withdraw. + + The `ref` field contains the id of the order or transfer which + created the hold. + + Args: + account_id (str): Account id to get holds of. + kwargs (dict): Additional HTTP request parameters. + + Returns: + generator(list): Hold information for the account. Example:: + [ + { + "id": "82dcd140-c3c7-4507-8de4-2c529cd1a28f", + "account_id": "e0b3f39a-183d-453e-b754-0c13e5bab0b3", + "created_at": "2014-11-06T10:34:47.123456Z", + "updated_at": "2014-11-06T10:40:47.123456Z", + "amount": "4.23", + "type": "order", + "ref": "0a205de4-dd35-4370-a285-fe8fc375a273", + }, + { + ... + } + ] + + """ + endpoint = '/accounts/{}/holds'.format(account_id) + return self._send_paginated_message(endpoint, params=kwargs) + + + def convert_stablecoin(self, amount, from_currency, to_currency): + """ Convert stablecoin. + + Args: + amount (Decimal): The amount to convert. + from_currency (str): Currency type (eg. 'USDC') + to_currency (str): Currency type (eg. 'USD'). + + Returns: + dict: Conversion details. Example:: + { + "id": "8942caee-f9d5-4600-a894-4811268545db", + "amount": "10000.00", + "from_account_id": "7849cc79-8b01-4793-9345-bc6b5f08acce", + "to_account_id": "105c3e58-0898-4106-8283-dc5781cda07b", + "from": "USD", + "to": "USDC" + } + + """ + params = {'from': from_currency, + 'to': to_currency, + 'amount': amount} + return self._send_message('post', '/conversions', data=json.dumps(params)) + + + def place_order(self, product_id, side, order_type=None, **kwargs): + """ Place an order. + + The three order types (limit, market, and stop) can be placed using this + method. Specific methods are provided for each order type, but if a + more generic interface is desired this method is available. + + Args: + product_id (str): Product to order (eg. 'BTC-USD') + side (str): Order side ('buy' or 'sell) + order_type (str): Order type ('limit', or 'market') + **client_oid (str): Order ID selected by you to identify your order. + This should be a UUID, which will be broadcast in the public + feed for `received` messages. + **stp (str): Self-trade prevention flag. cbpro doesn't allow self- + trading. This behavior can be modified with this flag. + Options: + 'dc' Decrease and Cancel (default) + 'co' Cancel oldest + 'cn' Cancel newest + 'cb' Cancel both + **overdraft_enabled (Optional[bool]): If true funding above and + beyond the account balance will be provided by margin, as + necessary. + **funding_amount (Optional[Decimal]): Amount of margin funding to be + provided for the order. Mutually exclusive with + `overdraft_enabled`. + **kwargs: Additional arguments can be specified for different order + types. See the limit/market/stop order methods for details. + + Returns: + dict: Order details. Example:: + { + "id": "d0c5340b-6d6c-49d9-b567-48c4bfca13d2", + "price": "0.10000000", + "size": "0.01000000", + "product_id": "BTC-USD", + "side": "buy", + "stp": "dc", + "type": "limit", + "time_in_force": "GTC", + "post_only": false, + "created_at": "2016-12-08T20:02:28.53864Z", + "fill_fees": "0.0000000000000000", + "filled_size": "0.00000000", + "executed_value": "0.0000000000000000", + "status": "pending", + "settled": false + } + + """ + # Margin parameter checks + if kwargs.get('overdraft_enabled') is not None and \ + kwargs.get('funding_amount') is not None: + raise ValueError('Margin funding must be specified through use of ' + 'overdraft or by setting a funding amount, but not' + ' both') + + # Limit order checks + if order_type == 'limit': + if kwargs.get('cancel_after') is not None and \ + kwargs.get('time_in_force') != 'GTT': + raise ValueError('May only specify a cancel period when time ' + 'in_force is `GTT`') + if kwargs.get('post_only') is not None and kwargs.get('time_in_force') in \ + ['IOC', 'FOK']: + raise ValueError('post_only is invalid when time in force is ' + '`IOC` or `FOK`') + + # Market and stop order checks + if order_type == 'market' or kwargs.get('stop'): + if not (kwargs.get('size') is None) ^ (kwargs.get('funds') is None): + raise ValueError('Either `size` or `funds` must be specified ' + 'for market/stop orders (but not both).') + + # Build params dict + params = {'product_id': product_id, + 'side': side, + 'type': order_type} + params.update(kwargs) + return self._send_message('post', '/orders', data=json.dumps(params)) + + def buy(self, product_id, order_type, **kwargs): + """Place a buy order. + + This is included to maintain backwards compatibility with older versions + of cbpro-Python. For maximum support from docstrings and function + signatures see the order type-specific functions place_limit_order, + place_market_order, and place_stop_order. + + Args: + product_id (str): Product to order (eg. 'BTC-USD') + order_type (str): Order type ('limit', 'market', or 'stop') + **kwargs: Additional arguments can be specified for different order + types. + + Returns: + dict: Order details. See `place_order` for example. + + """ + return self.place_order(product_id, 'buy', order_type, **kwargs) + + def sell(self, product_id, order_type, **kwargs): + """Place a sell order. + + This is included to maintain backwards compatibility with older versions + of cbpro-Python. For maximum support from docstrings and function + signatures see the order type-specific functions place_limit_order, + place_market_order, and place_stop_order. + + Args: + product_id (str): Product to order (eg. 'BTC-USD') + order_type (str): Order type ('limit', 'market', or 'stop') + **kwargs: Additional arguments can be specified for different order + types. + + Returns: + dict: Order details. See `place_order` for example. + + """ + return self.place_order(product_id, 'sell', order_type, **kwargs) + + def place_limit_order(self, product_id, side, price, size, + client_oid=None, + stp=None, + time_in_force=None, + cancel_after=None, + post_only=None, + overdraft_enabled=None, + funding_amount=None): + """Place a limit order. + + Args: + product_id (str): Product to order (eg. 'BTC-USD') + side (str): Order side ('buy' or 'sell) + price (Decimal): Price per cryptocurrency + size (Decimal): Amount of cryptocurrency to buy or sell + client_oid (Optional[str]): User-specified Order ID + stp (Optional[str]): Self-trade prevention flag. See `place_order` + for details. + time_in_force (Optional[str]): Time in force. Options: + 'GTC' Good till canceled + 'GTT' Good till time (set by `cancel_after`) + 'IOC' Immediate or cancel + 'FOK' Fill or kill + cancel_after (Optional[str]): Cancel after this period for 'GTT' + orders. Options are 'min', 'hour', or 'day'. + post_only (Optional[bool]): Indicates that the order should only + make liquidity. If any part of the order results in taking + liquidity, the order will be rejected and no part of it will + execute. + overdraft_enabled (Optional[bool]): If true funding above and + beyond the account balance will be provided by margin, as + necessary. + funding_amount (Optional[Decimal]): Amount of margin funding to be + provided for the order. Mutually exclusive with + `overdraft_enabled`. + + Returns: + dict: Order details. See `place_order` for example. + + """ + params = {'product_id': product_id, + 'side': side, + 'order_type': 'limit', + 'price': price, + 'size': size, + 'client_oid': client_oid, + 'stp': stp, + 'time_in_force': time_in_force, + 'cancel_after': cancel_after, + 'post_only': post_only, + 'overdraft_enabled': overdraft_enabled, + 'funding_amount': funding_amount} + params = dict((k, v) for k, v in params.items() if v is not None) + + return self.place_order(**params) + + def place_market_order(self, product_id, side, size=None, funds=None, + client_oid=None, + stp=None, + overdraft_enabled=None, + funding_amount=None): + """ Place market order. + + Args: + product_id (str): Product to order (eg. 'BTC-USD') + side (str): Order side ('buy' or 'sell) + size (Optional[Decimal]): Desired amount in crypto. Specify this or + `funds`. + funds (Optional[Decimal]): Desired amount of quote currency to use. + Specify this or `size`. + client_oid (Optional[str]): User-specified Order ID + stp (Optional[str]): Self-trade prevention flag. See `place_order` + for details. + overdraft_enabled (Optional[bool]): If true funding above and + beyond the account balance will be provided by margin, as + necessary. + funding_amount (Optional[Decimal]): Amount of margin funding to be + provided for the order. Mutually exclusive with + `overdraft_enabled`. + + Returns: + dict: Order details. See `place_order` for example. + + """ + params = {'product_id': product_id, + 'side': side, + 'order_type': 'market', + 'size': size, + 'funds': funds, + 'client_oid': client_oid, + 'stp': stp, + 'overdraft_enabled': overdraft_enabled, + 'funding_amount': funding_amount} + params = dict((k, v) for k, v in params.items() if v is not None) + + return self.place_order(**params) + + def place_stop_order(self, product_id, stop_type, price, size=None, funds=None, + client_oid=None, + stp=None, + overdraft_enabled=None, + funding_amount=None): + """ Place stop order. + + Args: + product_id (str): Product to order (eg. 'BTC-USD') + stop_type(str): Stop type ('entry' or 'loss') + loss: Triggers when the last trade price changes to a value at or below the stop_price. + entry: Triggers when the last trade price changes to a value at or above the stop_price + price (Decimal): Desired price at which the stop order triggers. + size (Optional[Decimal]): Desired amount in crypto. Specify this or + `funds`. + funds (Optional[Decimal]): Desired amount of quote currency to use. + Specify this or `size`. + client_oid (Optional[str]): User-specified Order ID + stp (Optional[str]): Self-trade prevention flag. See `place_order` + for details. + overdraft_enabled (Optional[bool]): If true funding above and + beyond the account balance will be provided by margin, as + necessary. + funding_amount (Optional[Decimal]): Amount of margin funding to be + provided for the order. Mutually exclusive with + `overdraft_enabled`. + + Returns: + dict: Order details. See `place_order` for example. + + """ + + if stop_type == 'loss': + side = 'sell' + elif stop_type == 'entry': + side = 'buy' + else: + raise ValueError('Invalid stop_type for stop order: ' + stop_type) + + params = {'product_id': product_id, + 'side': side, + 'price': price, + 'order_type': None, + 'stop': stop_type, + 'stop_price': price, + 'size': size, + 'funds': funds, + 'client_oid': client_oid, + 'stp': stp, + 'overdraft_enabled': overdraft_enabled, + 'funding_amount': funding_amount} + params = dict((k, v) for k, v in params.items() if v is not None) + + return self.place_order(**params) + + def cancel_order(self, order_id): + """ Cancel a previously placed order. + + If the order had no matches during its lifetime its record may + be purged. This means the order details will not be available + with get_order(order_id). If the order could not be canceled + (already filled or previously canceled, etc), then an error + response will indicate the reason in the message field. + + **Caution**: The order id is the server-assigned order id and + not the optional client_oid. + + Args: + order_id (str): The order_id of the order you want to cancel + + Returns: + list: Containing the order_id of cancelled order. Example:: + [ "c5ab5eae-76be-480e-8961-00792dc7e138" ] + + """ + return self._send_message('delete', '/orders/' + order_id) + + def cancel_all(self, product_id=None): + """ With best effort, cancel all open orders. + + Args: + product_id (Optional[str]): Only cancel orders for this + product_id + + Returns: + list: A list of ids of the canceled orders. Example:: + [ + "144c6f8e-713f-4682-8435-5280fbe8b2b4", + "debe4907-95dc-442f-af3b-cec12f42ebda", + "cf7aceee-7b08-4227-a76c-3858144323ab", + "dfc5ae27-cadb-4c0c-beef-8994936fde8a", + "34fecfbf-de33-4273-b2c6-baf8e8948be4" + ] + + """ + if product_id is not None: + params = {'product_id': product_id} + else: + params = None + return self._send_message('delete', '/orders', params=params) + + def get_order(self, order_id): + """ Get a single order by order id. + + If the order is canceled the response may have status code 404 + if the order had no matches. + + **Caution**: Open orders may change state between the request + and the response depending on market conditions. + + Args: + order_id (str): The order to get information of. + + Returns: + dict: Containing information on order. Example:: + { + "created_at": "2017-06-18T00:27:42.920136Z", + "executed_value": "0.0000000000000000", + "fill_fees": "0.0000000000000000", + "filled_size": "0.00000000", + "id": "9456f388-67a9-4316-bad1-330c5353804f", + "post_only": true, + "price": "1.00000000", + "product_id": "BTC-USD", + "settled": false, + "side": "buy", + "size": "1.00000000", + "status": "pending", + "stp": "dc", + "time_in_force": "GTC", + "type": "limit" + } + + """ + return self._send_message('get', '/orders/' + order_id) + + def get_orders(self, product_id=None, status=None, **kwargs): + """ List your current open orders. + + This method returns a generator which may make multiple HTTP requests + while iterating through it. + + Only open or un-settled orders are returned. As soon as an + order is no longer open and settled, it will no longer appear + in the default request. + + Orders which are no longer resting on the order book, will be + marked with the 'done' status. There is a small window between + an order being 'done' and 'settled'. An order is 'settled' when + all of the fills have settled and the remaining holds (if any) + have been removed. + + For high-volume trading it is strongly recommended that you + maintain your own list of open orders and use one of the + streaming market data feeds to keep it updated. You should poll + the open orders endpoint once when you start trading to obtain + the current state of any open orders. + + Args: + product_id (Optional[str]): Only list orders for this + product + status (Optional[list/str]): Limit list of orders to + this status or statuses. Passing 'all' returns orders + of all statuses. + ** Options: 'open', 'pending', 'active', 'done', + 'settled' + ** default: ['open', 'pending', 'active'] + + Returns: + list: Containing information on orders. Example:: + [ + { + "id": "d0c5340b-6d6c-49d9-b567-48c4bfca13d2", + "price": "0.10000000", + "size": "0.01000000", + "product_id": "BTC-USD", + "side": "buy", + "stp": "dc", + "type": "limit", + "time_in_force": "GTC", + "post_only": false, + "created_at": "2016-12-08T20:02:28.53864Z", + "fill_fees": "0.0000000000000000", + "filled_size": "0.00000000", + "executed_value": "0.0000000000000000", + "status": "open", + "settled": false + }, + { + ... + } + ] + + """ + params = kwargs + if product_id is not None: + params['product_id'] = product_id + if status is not None: + params['status'] = status + return self._send_paginated_message('/orders', params=params) + + def get_fills(self, product_id=None, order_id=None, **kwargs): + """ Get a list of recent fills. + + As of 8/23/18 - Requests without either order_id or product_id + will be rejected + + This method returns a generator which may make multiple HTTP requests + while iterating through it. + + Fees are recorded in two stages. Immediately after the matching + engine completes a match, the fill is inserted into our + datastore. Once the fill is recorded, a settlement process will + settle the fill and credit both trading counterparties. + + The 'fee' field indicates the fees charged for this fill. + + The 'liquidity' field indicates if the fill was the result of a + liquidity provider or liquidity taker. M indicates Maker and T + indicates Taker. + + Args: + product_id (str): Limit list to this product_id + order_id (str): Limit list to this order_id + kwargs (dict): Additional HTTP request parameters. + + Returns: + list: Containing information on fills. Example:: + [ + { + "trade_id": 74, + "product_id": "BTC-USD", + "price": "10.00", + "size": "0.01", + "order_id": "d50ec984-77a8-460a-b958-66f114b0de9b", + "created_at": "2014-11-07T22:19:28.578544Z", + "liquidity": "T", + "fee": "0.00025", + "settled": true, + "side": "buy" + }, + { + ... + } + ] + + """ + if (product_id is None) and (order_id is None): + raise ValueError('Either product_id or order_id must be specified.') + + params = {} + if product_id: + params['product_id'] = product_id + if order_id: + params['order_id'] = order_id + params.update(kwargs) + + return self._send_paginated_message('/fills', params=params) + + def get_fundings(self, status=None, **kwargs): + """ Every order placed with a margin profile that draws funding + will create a funding record. + + This method returns a generator which may make multiple HTTP requests + while iterating through it. + + Args: + status (list/str): Limit funding records to these statuses. + ** Options: 'outstanding', 'settled', 'rejected' + kwargs (dict): Additional HTTP request parameters. + + Returns: + list: Containing information on margin funding. Example:: + [ + { + "id": "b93d26cd-7193-4c8d-bfcc-446b2fe18f71", + "order_id": "b93d26cd-7193-4c8d-bfcc-446b2fe18f71", + "profile_id": "d881e5a6-58eb-47cd-b8e2-8d9f2e3ec6f6", + "amount": "1057.6519956381537500", + "status": "settled", + "created_at": "2017-03-17T23:46:16.663397Z", + "currency": "USD", + "repaid_amount": "1057.6519956381537500", + "default_amount": "0", + "repaid_default": false + }, + { + ... + } + ] + + """ + params = {} + if status is not None: + params['status'] = status + params.update(kwargs) + return self._send_paginated_message('/funding', params=params) + + def repay_funding(self, amount, currency): + """ Repay funding. Repays the older funding records first. + + Args: + amount (int): Amount of currency to repay + currency (str): The currency, example USD + + Returns: + Not specified by cbpro. + + """ + params = { + 'amount': amount, + 'currency': currency # example: USD + } + return self._send_message('post', '/funding/repay', + data=json.dumps(params)) + + def margin_transfer(self, margin_profile_id, transfer_type, currency, + amount): + """ Transfer funds between your standard profile and a margin profile. + + Args: + margin_profile_id (str): Margin profile ID to withdraw or deposit + from. + transfer_type (str): 'deposit' or 'withdraw' + currency (str): Currency to transfer (eg. 'USD') + amount (Decimal): Amount to transfer + + Returns: + dict: Transfer details. Example:: + { + "created_at": "2017-01-25T19:06:23.415126Z", + "id": "80bc6b74-8b1f-4c60-a089-c61f9810d4ab", + "user_id": "521c20b3d4ab09621f000011", + "profile_id": "cda95996-ac59-45a3-a42e-30daeb061867", + "margin_profile_id": "45fa9e3b-00ba-4631-b907-8a98cbdf21be", + "type": "deposit", + "amount": "2", + "currency": "USD", + "account_id": "23035fc7-0707-4b59-b0d2-95d0c035f8f5", + "margin_account_id": "e1d9862c-a259-4e83-96cd-376352a9d24d", + "margin_product_id": "BTC-USD", + "status": "completed", + "nonce": 25 + } + + """ + params = {'margin_profile_id': margin_profile_id, + 'type': transfer_type, + 'currency': currency, # example: USD + 'amount': amount} + return self._send_message('post', '/profiles/margin-transfer', + data=json.dumps(params)) + + def get_position(self): + """ Get An overview of your margin profile. + + Returns: + dict: Details about funding, accounts, and margin call. + + """ + return self._send_message('get', '/position') + + def close_position(self, repay_only): + """ Close position. + + Args: + repay_only (bool): Undocumented by cbpro. + + Returns: + Undocumented + + """ + params = {'repay_only': repay_only} + return self._send_message('post', '/position/close', + data=json.dumps(params)) + + def deposit(self, amount, currency, payment_method_id): + """ Deposit funds from a payment method. + + See AuthenticatedClient.get_payment_methods() to receive + information regarding payment methods. + + Args: + amount (Decmial): The amount to deposit. + currency (str): The type of currency. + payment_method_id (str): ID of the payment method. + + Returns: + dict: Information about the deposit. Example:: + { + "id": "593533d2-ff31-46e0-b22e-ca754147a96a", + "amount": "10.00", + "currency": "USD", + "payout_at": "2016-08-20T00:31:09Z" + } + + """ + params = {'amount': amount, + 'currency': currency, + 'payment_method_id': payment_method_id} + return self._send_message('post', '/deposits/payment-method', + data=json.dumps(params)) + + def coinbase_deposit(self, amount, currency, coinbase_account_id): + """ Deposit funds from a coinbase account. + + You can move funds between your Coinbase accounts and your cbpro + trading accounts within your daily limits. Moving funds between + Coinbase and cbpro is instant and free. + + See AuthenticatedClient.get_coinbase_accounts() to receive + information regarding your coinbase_accounts. + + Args: + amount (Decimal): The amount to deposit. + currency (str): The type of currency. + coinbase_account_id (str): ID of the coinbase account. + + Returns: + dict: Information about the deposit. Example:: + { + "id": "593533d2-ff31-46e0-b22e-ca754147a96a", + "amount": "10.00", + "currency": "BTC", + } + + """ + params = {'amount': amount, + 'currency': currency, + 'coinbase_account_id': coinbase_account_id} + return self._send_message('post', '/deposits/coinbase-account', + data=json.dumps(params)) + + def withdraw(self, amount, currency, payment_method_id): + """ Withdraw funds to a payment method. + + See AuthenticatedClient.get_payment_methods() to receive + information regarding payment methods. + + Args: + amount (Decimal): The amount to withdraw. + currency (str): Currency type (eg. 'BTC') + payment_method_id (str): ID of the payment method. + + Returns: + dict: Withdraw details. Example:: + { + "id":"593533d2-ff31-46e0-b22e-ca754147a96a", + "amount": "10.00", + "currency": "USD", + "payout_at": "2016-08-20T00:31:09Z" + } + + """ + params = {'amount': amount, + 'currency': currency, + 'payment_method_id': payment_method_id} + return self._send_message('post', '/withdrawals/payment-method', + data=json.dumps(params)) + + def coinbase_withdraw(self, amount, currency, coinbase_account_id): + """ Withdraw funds to a coinbase account. + + You can move funds between your Coinbase accounts and your cbpro + trading accounts within your daily limits. Moving funds between + Coinbase and cbpro is instant and free. + + See AuthenticatedClient.get_coinbase_accounts() to receive + information regarding your coinbase_accounts. + + Args: + amount (Decimal): The amount to withdraw. + currency (str): The type of currency (eg. 'BTC') + coinbase_account_id (str): ID of the coinbase account. + + Returns: + dict: Information about the deposit. Example:: + { + "id":"593533d2-ff31-46e0-b22e-ca754147a96a", + "amount":"10.00", + "currency": "BTC", + } + + """ + params = {'amount': amount, + 'currency': currency, + 'coinbase_account_id': coinbase_account_id} + return self._send_message('post', '/withdrawals/coinbase-account', + data=json.dumps(params)) + + def crypto_withdraw(self, amount, currency, crypto_address): + """ Withdraw funds to a crypto address. + + Args: + amount (Decimal): The amount to withdraw + currency (str): The type of currency (eg. 'BTC') + crypto_address (str): Crypto address to withdraw to. + + Returns: + dict: Withdraw details. Example:: + { + "id":"593533d2-ff31-46e0-b22e-ca754147a96a", + "amount":"10.00", + "currency": "BTC", + } + + """ + params = {'amount': amount, + 'currency': currency, + 'crypto_address': crypto_address} + return self._send_message('post', '/withdrawals/crypto', + data=json.dumps(params)) + + def get_payment_methods(self): + """ Get a list of your payment methods. + + Returns: + list: Payment method details. + + """ + return self._send_message('get', '/payment-methods') + + def get_coinbase_accounts(self): + """ Get a list of your coinbase accounts. + + Returns: + list: Coinbase account details. + + """ + return self._send_message('get', '/coinbase-accounts') + + def create_report(self, report_type, start_date, end_date, product_id=None, + account_id=None, report_format='pdf', email=None): + """ Create report of historic information about your account. + + The report will be generated when resources are available. Report status + can be queried via `get_report(report_id)`. + + Args: + report_type (str): 'fills' or 'account' + start_date (str): Starting date for the report in ISO 8601 + end_date (str): Ending date for the report in ISO 8601 + product_id (Optional[str]): ID of the product to generate a fills + report for. Required if account_type is 'fills' + account_id (Optional[str]): ID of the account to generate an account + report for. Required if report_type is 'account'. + report_format (Optional[str]): 'pdf' or 'csv'. Default is 'pdf'. + email (Optional[str]): Email address to send the report to. + + Returns: + dict: Report details. Example:: + { + "id": "0428b97b-bec1-429e-a94c-59232926778d", + "type": "fills", + "status": "pending", + "created_at": "2015-01-06T10:34:47.000Z", + "completed_at": undefined, + "expires_at": "2015-01-13T10:35:47.000Z", + "file_url": undefined, + "params": { + "start_date": "2014-11-01T00:00:00.000Z", + "end_date": "2014-11-30T23:59:59.000Z" + } + } + + """ + params = {'type': report_type, + 'start_date': start_date, + 'end_date': end_date, + 'format': report_format} + if product_id is not None: + params['product_id'] = product_id + if account_id is not None: + params['account_id'] = account_id + if email is not None: + params['email'] = email + + return self._send_message('post', '/reports', + data=json.dumps(params)) + + def get_report(self, report_id): + """ Get report status. + + Use to query a specific report once it has been requested. + + Args: + report_id (str): Report ID + + Returns: + dict: Report details, including file url once it is created. + + """ + return self._send_message('get', '/reports/' + report_id) + + def get_trailing_volume(self): + """ Get your 30-day trailing volume for all products. + + This is a cached value that's calculated every day at midnight UTC. + + Returns: + list: 30-day trailing volumes. Example:: + [ + { + "product_id": "BTC-USD", + "exchange_volume": "11800.00000000", + "volume": "100.00000000", + "recorded_at": "1973-11-29T00:05:01.123456Z" + }, + { + ... + } + ] + + """ + return self._send_message('get', '/users/self/trailing-volume') + + def get_fees(self): + """ Get your maker & taker fee rates and 30-day trailing volume. + + Returns: + dict: Fee information and USD volume:: + { + "maker_fee_rate": "0.0015", + "taker_fee_rate": "0.0025", + "usd_volume": "25000.00" + } + """ + return self._send_message('get', '/fees') diff --git a/gdax/gdax_auth.py b/cbpro/cbpro_auth.py similarity index 92% rename from gdax/gdax_auth.py rename to cbpro/cbpro_auth.py index c4217331..db6561fe 100644 --- a/gdax/gdax_auth.py +++ b/cbpro/cbpro_auth.py @@ -5,8 +5,8 @@ from requests.auth import AuthBase -class GdaxAuth(AuthBase): - # Provided by gdax: https://docs.gdax.com/#signing-a-message +class CBProAuth(AuthBase): + # Provided by CBPro: https://docs.pro.coinbase.com/#signing-a-message def __init__(self, api_key, secret_key, passphrase): self.api_key = api_key self.secret_key = secret_key diff --git a/gdax/order_book.py b/cbpro/order_book.py similarity index 67% rename from gdax/order_book.py rename to cbpro/order_book.py index 80d4a610..1a8525e5 100644 --- a/gdax/order_book.py +++ b/cbpro/order_book.py @@ -1,20 +1,19 @@ # -# gdax/order_book.py +# cbpro/order_book.py # David Caseria # -# Live order book updated from the gdax Websocket Feed +# Live order book updated from the Coinbase Websocket Feed from sortedcontainers import SortedDict from decimal import Decimal import pickle -from gdax.public_client import PublicClient -from gdax.websocket_client import WebsocketClient +from cbpro.public_client import PublicClient +from cbpro.websocket_client import WebsocketClient -class OrderBook(WebsocketClient): +class OrderBook(object): def __init__(self, product_id='BTC-USD', log_to=None): - super(OrderBook, self).__init__(products=product_id) self._asks = SortedDict() self._bids = SortedDict() self._client = PublicClient() @@ -23,18 +22,7 @@ def __init__(self, product_id='BTC-USD', log_to=None): if self._log_to: assert hasattr(self._log_to, 'write') self._current_ticker = None - - @property - def product_id(self): - ''' Currently OrderBook only supports a single product even though it is stored as a list of products. ''' - return self.products[0] - - def on_open(self): - self._sequence = -1 - print("-- Subscribed to OrderBook! --\n") - - def on_close(self): - print("\n-- OrderBook Socket Closed! --") + self.product_id = product_id def reset_book(self): self._asks = SortedDict() @@ -56,39 +44,37 @@ def reset_book(self): }) self._sequence = res['sequence'] - def on_message(self, message): - if self._log_to: - pickle.dump(message, self._log_to) + def process_message(self, message): + if message.get('product_id') == self.product_id: + if self._log_to: + pickle.dump(message, self._log_to) - sequence = message['sequence'] - if self._sequence == -1: - self.reset_book() - return - if sequence <= self._sequence: - # ignore older messages (e.g. before order book initialization from getProductOrderBook) - return - elif sequence > self._sequence + 1: - self.on_sequence_gap(self._sequence, sequence) - return + sequence = message.get('sequence', -1) + if self._sequence == -1: + self.reset_book() + return + if sequence <= self._sequence: + # ignore older messages (e.g. before order book initialization from getProductOrderBook) + return + elif sequence > self._sequence + 1: + self.on_sequence_gap(self._sequence, sequence) + return - msg_type = message['type'] - if msg_type == 'open': - self.add(message) - elif msg_type == 'done' and 'price' in message: - self.remove(message) - elif msg_type == 'match': - self.match(message) - self._current_ticker = message - elif msg_type == 'change': - self.change(message) + msg_type = message['type'] + if msg_type == 'open': + self.add(message) + elif msg_type == 'done' and 'price' in message: + self.remove(message) + elif msg_type == 'match': + self.match(message) + self._current_ticker = message + elif msg_type == 'change': + self.change(message) - self._sequence = sequence + self._sequence = sequence def on_sequence_gap(self, gap_start, gap_end): self.reset_book() - print('Error: messages missing ({} - {}). Re-initializing book at sequence.'.format( - gap_start, gap_end, self._sequence)) - def add(self, order): order = { @@ -248,7 +234,6 @@ def set_bids(self, price, bids): import time import datetime as dt - class OrderBookConsole(OrderBook): ''' Logs real-time changes to the bid-ask spread to the console ''' @@ -261,38 +246,55 @@ def __init__(self, product_id=None): self._bid_depth = None self._ask_depth = None - def on_message(self, message): - super(OrderBookConsole, self).on_message(message) - - # Calculate newest bid-ask spread - bid = self.get_bid() - bids = self.get_bids(bid) - bid_depth = sum([b['size'] for b in bids]) - ask = self.get_ask() - asks = self.get_asks(ask) - ask_depth = sum([a['size'] for a in asks]) - - if self._bid == bid and self._ask == ask and self._bid_depth == bid_depth and self._ask_depth == ask_depth: - # If there are no changes to the bid-ask spread since the last update, no need to print - pass - else: - # If there are differences, update the cache - self._bid = bid - self._ask = ask - self._bid_depth = bid_depth - self._ask_depth = ask_depth - print('{} {} bid: {:.3f} @ {:.2f}\task: {:.3f} @ {:.2f}'.format( - dt.datetime.now(), self.product_id, bid_depth, bid, ask_depth, ask)) - - order_book = OrderBookConsole() - order_book.start() + def process_message(self, message): + if message.get('product_id') == self.product_id: + super(OrderBookConsole, self).process_message(message) + + try: + # Calculate newest bid-ask spread + bid = self.get_bid() + bids = self.get_bids(bid) + bid_depth = sum([b['size'] for b in bids]) + ask = self.get_ask() + asks = self.get_asks(ask) + ask_depth = sum([a['size'] for a in asks]) + + if self._bid == bid and self._ask == ask and self._bid_depth == bid_depth and self._ask_depth == ask_depth: + # If there are no changes to the bid-ask spread since the last update, no need to print + pass + else: + # If there are differences, update the cache + self._bid = bid + self._ask = ask + self._bid_depth = bid_depth + self._ask_depth = ask_depth + print('{} {} bid: {:.3f} @ {:.2f}\task: {:.3f} @ {:.2f}'.format( + dt.datetime.now(), self.product_id, bid_depth, bid, ask_depth, ask)) + except Exception: + pass + + class WebsocketConsole(WebsocketClient): + def on_open(self): + self.products = ['BTC-USD', 'ETH-USD'] + self.order_book_btc = OrderBookConsole(product_id='BTC-USD') + self.order_book_eth = OrderBookConsole(product_id='ETH-USD') + + def on_message(self, msg): + self.order_book_btc.process_message(msg) + self.order_book_eth.process_message(msg) + + wsClient = WebsocketConsole() + wsClient.start() + time.sleep(10) try: while True: - time.sleep(10) + pass except KeyboardInterrupt: - order_book.close() + wsClient.close() + except Exception: + pass - if order_book.error: + if wsClient.error: sys.exit(1) else: sys.exit(0) diff --git a/gdax/public_client.py b/cbpro/public_client.py similarity index 63% rename from gdax/public_client.py rename to cbpro/public_client.py index a998873a..e854a163 100644 --- a/gdax/public_client.py +++ b/cbpro/public_client.py @@ -1,39 +1,33 @@ # -# GDAX/PublicClient.py +# cbpro/PublicClient.py # Daniel Paquin # -# For public requests to the GDAX exchange +# For public requests to the Coinbase exchange import requests class PublicClient(object): - """GDAX public client API. + """cbpro public client API. All requests default to the `product_id` specified at object creation if not otherwise specified. Attributes: - url (Optional[str]): API URL. Defaults to GDAX API. + url (Optional[str]): API URL. Defaults to cbpro API. """ - def __init__(self, api_url='https://api.gdax.com', timeout=30): - """Create GDAX API public client. + def __init__(self, api_url='https://api.pro.coinbase.com', timeout=30): + """Create cbpro API public client. Args: - api_url (Optional[str]): API URL. Defaults to GDAX API. + api_url (Optional[str]): API URL. Defaults to cbpro API. """ self.url = api_url.rstrip('/') - self.timeout = timeout - - def _get(self, path, params=None): - """Perform get request""" - - r = requests.get(self.url + path, params=params, timeout=self.timeout) - # r.raise_for_status() - return r.json() + self.auth = None + self.session = requests.Session() def get_products(self): """Get a list of available currency pairs for trading. @@ -53,7 +47,7 @@ def get_products(self): ] """ - return self._get('/products') + return self._send_message('get', '/products') def get_product_order_book(self, product_id, level=1): """Get a list of open orders for a product. @@ -90,10 +84,10 @@ def get_product_order_book(self, product_id, level=1): } """ - - # Supported levels are 1, 2 or 3 - level = level if level in range(1, 4) else 1 - return self._get('/products/{}/book'.format(str(product_id)), params={'level': level}) + params = {'level': level} + return self._send_message('get', + '/products/{}/book'.format(product_id), + params=params) def get_product_ticker(self, product_id): """Snapshot about the last trade (tick), best bid/ask and 24h volume. @@ -117,10 +111,15 @@ def get_product_ticker(self, product_id): } """ - return self._get('/products/{}/ticker'.format(str(product_id))) + return self._send_message('get', + '/products/{}/ticker'.format(product_id)) - def get_product_trades(self, product_id, before='', after='', limit='', result=[]): + def get_product_trades(self, product_id, before='', after='', limit=None, result=None): """List the latest trades for a product. + + This method returns a generator which may make multiple HTTP requests + while iterating through it. + Args: product_id (str): Product before (Optional[str]): start time in ISO 8601 @@ -144,34 +143,8 @@ def get_product_trades(self, product_id, before='', after='', limit='', result=[ "side": "sell" }] """ - url = self.url + '/products/{}/trades'.format(str(product_id)) - params = {} - - if before: - params['before'] = str(before) - if after: - params['after'] = str(after) - if limit and limit < 100: - # the default limit is 100 - # we only add it if the limit is less than 100 - params['limit'] = limit - - r = requests.get(url, params=params) - # r.raise_for_status() - - result.extend(r.json()) - - if 'cb-after' in r.headers and limit is not len(result): - # update limit - limit -= len(result) - if limit <= 0: - return result - - # TODO: need a way to ensure that we don't get rate-limited/blocked - # time.sleep(0.4) - return self.get_product_trades(product_id=product_id, after=r.headers['cb-after'], limit=limit, result=result) - - return result + return self._send_paginated_message('/products/{}/trades' + .format(product_id)) def get_product_historic_rates(self, product_id, start=None, end=None, granularity=None): @@ -199,10 +172,10 @@ def get_product_historic_rates(self, product_id, start=None, end=None, product_id (str): Product start (Optional[str]): Start time in ISO 8601 end (Optional[str]): End time in ISO 8601 - granularity (Optional[str]): Desired time slice in seconds + granularity (Optional[int]): Desired time slice in seconds Returns: - list: Historic candle data. Example:: + list: Historic candle data. Example: [ [ time, low, high, open, close, volume ], [ 1415398768, 0.32, 4.2, 0.35, 4.2, 12.3 ], @@ -218,12 +191,13 @@ def get_product_historic_rates(self, product_id, start=None, end=None, if granularity is not None: acceptedGrans = [60, 300, 900, 3600, 21600, 86400] if granularity not in acceptedGrans: - newGranularity = min(acceptedGrans, key=lambda x:abs(x-granularity)) - print(granularity,' is not a valid granularity level, using',newGranularity,' instead.') - granularity = newGranularity - params['granularity'] = granularity + raise ValueError( 'Specified granularity is {}, must be in approved values: {}'.format( + granularity, acceptedGrans) ) - return self._get('/products/{}/candles'.format(str(product_id)), params=params) + params['granularity'] = granularity + return self._send_message('get', + '/products/{}/candles'.format(product_id), + params=params) def get_product_24hr_stats(self, product_id): """Get 24 hr stats for the product. @@ -242,7 +216,8 @@ def get_product_24hr_stats(self, product_id): } """ - return self._get('/products/{}/stats'.format(str(product_id))) + return self._send_message('get', + '/products/{}/stats'.format(product_id)) def get_currencies(self): """List known currencies. @@ -260,7 +235,7 @@ def get_currencies(self): }] """ - return self._get('/currencies') + return self._send_message('get', '/currencies') def get_time(self): """Get the API server time. @@ -274,4 +249,63 @@ def get_time(self): } """ - return self._get('/time') + return self._send_message('get', '/time') + + def _send_message(self, method, endpoint, params=None, data=None): + """Send API request. + + Args: + method (str): HTTP method (get, post, delete, etc.) + endpoint (str): Endpoint (to be added to base URL) + params (Optional[dict]): HTTP request parameters + data (Optional[str]): JSON-encoded string payload for POST + + Returns: + dict/list: JSON response + + """ + url = self.url + endpoint + r = self.session.request(method, url, params=params, data=data, + auth=self.auth, timeout=30) + return r.json() + + def _send_paginated_message(self, endpoint, params=None): + """ Send API message that results in a paginated response. + + The paginated responses are abstracted away by making API requests on + demand as the response is iterated over. + + Paginated API messages support 3 additional parameters: `before`, + `after`, and `limit`. `before` and `after` are mutually exclusive. To + use them, supply an index value for that endpoint (the field used for + indexing varies by endpoint - get_fills() uses 'trade_id', for example). + `before`: Only get data that occurs more recently than index + `after`: Only get data that occurs further in the past than index + `limit`: Set amount of data per HTTP response. Default (and + maximum) of 100. + + Args: + endpoint (str): Endpoint (to be added to base URL) + params (Optional[dict]): HTTP request parameters + + Yields: + dict: API response objects + + """ + if params is None: + params = dict() + url = self.url + endpoint + while True: + r = self.session.get(url, params=params, auth=self.auth, timeout=30) + results = r.json() + for result in results: + yield result + # If there are no more pages, we're done. Otherwise update `after` + # param to get next page. + # If this request included `before` don't get any more pages - the + # cbpro API doesn't support multiple pages in that case. + if not r.headers.get('cb-after') or \ + params.get('before') is not None: + break + else: + params['after'] = r.headers['cb-after'] diff --git a/gdax/websocket_client.py b/cbpro/websocket_client.py similarity index 67% rename from gdax/websocket_client.py rename to cbpro/websocket_client.py index 47000b12..b0ffeb7d 100644 --- a/gdax/websocket_client.py +++ b/cbpro/websocket_client.py @@ -1,9 +1,9 @@ -# gdax/WebsocketClient.py +# cbpro/WebsocketClient.py # original author: Daniel Paquin # mongo "support" added by Drew Rice # # -# Template object to receive messages from the gdax Websocket Feed +# Template object to receive messages from the Coinbase Websocket Feed from __future__ import print_function import json @@ -14,17 +14,30 @@ from threading import Thread from websocket import create_connection, WebSocketConnectionClosedException from pymongo import MongoClient -from gdax.gdax_auth import get_auth_headers +from cbpro.cbpro_auth import get_auth_headers class WebsocketClient(object): - def __init__(self, url="wss://ws-feed.gdax.com", products=None, message_type="subscribe", mongo_collection=None, - should_print=True, auth=False, api_key="", api_secret="", api_passphrase="", channels=None): + def __init__( + self, + url="wss://ws-feed.pro.coinbase.com", + products=None, + message_type="subscribe", + mongo_collection=None, + should_print=True, + auth=False, + api_key="", + api_secret="", + api_passphrase="", + # Make channels a required keyword-only argument; see pep3102 + *, + # Channel options: ['ticker', 'user', 'matches', 'level2', 'full'] + channels): self.url = url self.products = products self.channels = channels self.type = message_type - self.stop = False + self.stop = True self.error = None self.ws = None self.thread = None @@ -44,6 +57,7 @@ def _go(): self.stop = False self.on_open() self.thread = Thread(target=_go) + self.keepalive = Thread(target=self._keepalive) self.thread.start() def _connect(self): @@ -56,34 +70,33 @@ def _connect(self): self.url = self.url[:-1] if self.channels is None: - sub_params = {'type': 'subscribe', 'product_ids': self.products} + self.channels = [{"name": "ticker", "product_ids": [product_id for product_id in self.products]}] + sub_params = {'type': 'subscribe', 'product_ids': self.products, 'channels': self.channels} else: sub_params = {'type': 'subscribe', 'product_ids': self.products, 'channels': self.channels} if self.auth: timestamp = str(time.time()) message = timestamp + 'GET' + '/users/self/verify' - message = message.encode('ascii') - hmac_key = base64.b64decode(self.api_secret) - signature = hmac.new(hmac_key, message, hashlib.sha256) - signature_b64 = base64.b64encode(signature.digest()).decode('utf-8').rstrip('\n') - sub_params['signature'] = signature_b64 - sub_params['key'] = self.api_key - sub_params['passphrase'] = self.api_passphrase - sub_params['timestamp'] = timestamp + auth_headers = get_auth_headers(timestamp, message, self.api_key, self.api_secret, self.api_passphrase) + sub_params['signature'] = auth_headers['CB-ACCESS-SIGN'] + sub_params['key'] = auth_headers['CB-ACCESS-KEY'] + sub_params['passphrase'] = auth_headers['CB-ACCESS-PASSPHRASE'] + sub_params['timestamp'] = auth_headers['CB-ACCESS-TIMESTAMP'] self.ws = create_connection(self.url) self.ws.send(json.dumps(sub_params)) + def _keepalive(self, interval=30): + while self.ws.connected: + self.ws.ping("keepalive") + time.sleep(interval) + def _listen(self): + self.keepalive.start() while not self.stop: try: - start_t = 0 - if time.time() - start_t >= 30: - # Set a 30 second ping to keep connection alive - self.ws.ping("keepalive") - start_t = time.time() data = self.ws.recv() msg = json.loads(data) except ValueError as e: @@ -99,11 +112,14 @@ def _disconnect(self): self.ws.close() except WebSocketConnectionClosedException as e: pass + finally: + self.keepalive.join() self.on_close() def close(self): - self.stop = True + self.stop = True # will only disconnect after next msg recv + self._disconnect() # force disconnect so threads can join self.thread.join() def on_open(self): @@ -128,13 +144,13 @@ def on_error(self, e, data=None): if __name__ == "__main__": import sys - import gdax + import cbpro import time - class MyWebsocketClient(gdax.WebsocketClient): + class MyWebsocketClient(cbpro.WebsocketClient): def on_open(self): - self.url = "wss://ws-feed.gdax.com/" + self.url = "wss://ws-feed.pro.coinbase.com/" self.products = ["BTC-USD", "ETH-USD"] self.message_count = 0 print("Let's count the messages!") diff --git a/contributors.txt b/contributors.txt index b1e5c495..3a6695b6 100644 --- a/contributors.txt +++ b/contributors.txt @@ -3,4 +3,5 @@ Leonard Lin Jeff Gibson David Caseria Paul Mestemaker -Drew Rice \ No newline at end of file +Drew Rice +Mike Cardillo \ No newline at end of file diff --git a/gdax/__init__.py b/gdax/__init__.py deleted file mode 100644 index cb5fda32..00000000 --- a/gdax/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from gdax.authenticated_client import AuthenticatedClient -from gdax.public_client import PublicClient -from gdax.websocket_client import WebsocketClient -from gdax.order_book import OrderBook diff --git a/gdax/authenticated_client.py b/gdax/authenticated_client.py deleted file mode 100644 index 3ee2f1c5..00000000 --- a/gdax/authenticated_client.py +++ /dev/null @@ -1,308 +0,0 @@ -# -# gdax/AuthenticatedClient.py -# Daniel Paquin -# -# For authenticated requests to the gdax exchange - -import hmac -import hashlib -import time -import requests -import base64 -import json -from requests.auth import AuthBase -from gdax.public_client import PublicClient -from gdax.gdax_auth import GdaxAuth - - -class AuthenticatedClient(PublicClient): - def __init__(self, key, b64secret, passphrase, api_url="https://api.gdax.com", timeout=30): - super(AuthenticatedClient, self).__init__(api_url) - self.auth = GdaxAuth(key, b64secret, passphrase) - self.timeout = timeout - - def get_account(self, account_id): - r = requests.get(self.url + '/accounts/' + account_id, auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - return r.json() - - def get_accounts(self): - return self.get_account('') - - def get_account_history(self, account_id): - result = [] - r = requests.get(self.url + '/accounts/{}/ledger'.format(account_id), auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - result.append(r.json()) - if "cb-after" in r.headers: - self.history_pagination(account_id, result, r.headers["cb-after"]) - return result - - def history_pagination(self, account_id, result, after): - r = requests.get(self.url + '/accounts/{}/ledger?after={}'.format(account_id, str(after)), auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - if r.json(): - result.append(r.json()) - if "cb-after" in r.headers: - self.history_pagination(account_id, result, r.headers["cb-after"]) - return result - - def get_account_holds(self, account_id): - result = [] - r = requests.get(self.url + '/accounts/{}/holds'.format(account_id), auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - result.append(r.json()) - if "cb-after" in r.headers: - self.holds_pagination(account_id, result, r.headers["cb-after"]) - return result - - def holds_pagination(self, account_id, result, after): - r = requests.get(self.url + '/accounts/{}/holds?after={}'.format(account_id, str(after)), auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - if r.json(): - result.append(r.json()) - if "cb-after" in r.headers: - self.holds_pagination(account_id, result, r.headers["cb-after"]) - return result - - def buy(self, **kwargs): - kwargs["side"] = "buy" - if "product_id" not in kwargs: - kwargs["product_id"] = self.product_id - r = requests.post(self.url + '/orders', - data=json.dumps(kwargs), - auth=self.auth, - timeout=self.timeout) - return r.json() - - def sell(self, **kwargs): - kwargs["side"] = "sell" - r = requests.post(self.url + '/orders', - data=json.dumps(kwargs), - auth=self.auth, - timeout=self.timeout) - return r.json() - - def cancel_order(self, order_id): - r = requests.delete(self.url + '/orders/' + order_id, auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - return r.json() - - def cancel_all(self, product_id=''): - url = self.url + '/orders/' - params = {} - if product_id: - params["product_id"] = product_id - r = requests.delete(url, auth=self.auth, params=params, timeout=self.timeout) - # r.raise_for_status() - return r.json() - - def get_order(self, order_id): - r = requests.get(self.url + '/orders/' + order_id, auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - return r.json() - - def get_orders(self, product_id='', status=[]): - result = [] - url = self.url + '/orders/' - params = {} - if product_id: - params["product_id"] = product_id - if status: - params["status"] = status - r = requests.get(url, auth=self.auth, params=params, timeout=self.timeout) - # r.raise_for_status() - result.append(r.json()) - if 'cb-after' in r.headers: - self.paginate_orders(product_id, status, result, r.headers['cb-after']) - return result - - def paginate_orders(self, product_id, status, result, after): - url = self.url + '/orders' - - params = { - "after": str(after), - } - if product_id: - params["product_id"] = product_id - if status: - params["status"] = status - r = requests.get(url, auth=self.auth, params=params, timeout=self.timeout) - # r.raise_for_status() - if r.json(): - result.append(r.json()) - if 'cb-after' in r.headers: - self.paginate_orders(product_id, status, result, r.headers['cb-after']) - return result - - def get_fills(self, order_id='', product_id='', before='', after='', limit=''): - result = [] - url = self.url + '/fills?' - if order_id: - url += "order_id={}&".format(str(order_id)) - if product_id: - url += "product_id={}&".format(product_id) - if before: - url += "before={}&".format(str(before)) - if after: - url += "after={}&".format(str(after)) - if limit: - url += "limit={}&".format(str(limit)) - r = requests.get(url, auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - result.append(r.json()) - if 'cb-after' in r.headers and limit is not len(r.json()): - return self.paginate_fills(result, r.headers['cb-after'], order_id=order_id, product_id=product_id) - return result - - def paginate_fills(self, result, after, order_id='', product_id=''): - url = self.url + '/fills?after={}&'.format(str(after)) - if order_id: - url += "order_id={}&".format(str(order_id)) - if product_id: - url += "product_id={}&".format(product_id) - r = requests.get(url, auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - if r.json(): - result.append(r.json()) - if 'cb-after' in r.headers: - return self.paginate_fills(result, r.headers['cb-after'], order_id=order_id, product_id=product_id) - return result - - def get_fundings(self, result='', status='', after=''): - if not result: - result = [] - url = self.url + '/funding?' - if status: - url += "status={}&".format(str(status)) - if after: - url += 'after={}&'.format(str(after)) - r = requests.get(url, auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - result.append(r.json()) - if 'cb-after' in r.headers: - return self.get_fundings(result, status=status, after=r.headers['cb-after']) - return result - - def repay_funding(self, amount='', currency=''): - payload = { - "amount": amount, - "currency": currency # example: USD - } - r = requests.post(self.url + "/funding/repay", data=json.dumps(payload), auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - return r.json() - - def margin_transfer(self, margin_profile_id="", transfer_type="", currency="", amount=""): - payload = { - "margin_profile_id": margin_profile_id, - "type": transfer_type, - "currency": currency, # example: USD - "amount": amount - } - r = requests.post(self.url + "/profiles/margin-transfer", data=json.dumps(payload), auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - return r.json() - - def get_position(self): - r = requests.get(self.url + "/position", auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - return r.json() - - def close_position(self, repay_only=""): - payload = { - "repay_only": repay_only or False - } - r = requests.post(self.url + "/position/close", data=json.dumps(payload), auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - return r.json() - - def deposit(self, amount="", currency="", payment_method_id=""): - payload = { - "amount": amount, - "currency": currency, - "payment_method_id": payment_method_id - } - r = requests.post(self.url + "/deposits/payment-method", data=json.dumps(payload), auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - return r.json() - - def coinbase_deposit(self, amount="", currency="", coinbase_account_id=""): - payload = { - "amount": amount, - "currency": currency, - "coinbase_account_id": coinbase_account_id - } - r = requests.post(self.url + "/deposits/coinbase-account", data=json.dumps(payload), auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - return r.json() - - def withdraw(self, amount="", currency="", payment_method_id=""): - payload = { - "amount": amount, - "currency": currency, - "payment_method_id": payment_method_id - } - r = requests.post(self.url + "/withdrawals/payment-method", data=json.dumps(payload), auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - return r.json() - - def coinbase_withdraw(self, amount="", currency="", coinbase_account_id=""): - payload = { - "amount": amount, - "currency": currency, - "coinbase_account_id": coinbase_account_id - } - r = requests.post(self.url + "/withdrawals/coinbase-account", data=json.dumps(payload), auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - return r.json() - - def crypto_withdraw(self, amount="", currency="", crypto_address=""): - payload = { - "amount": amount, - "currency": currency, - "crypto_address": crypto_address - } - r = requests.post(self.url + "/withdrawals/crypto", data=json.dumps(payload), auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - return r.json() - - def get_payment_methods(self): - r = requests.get(self.url + "/payment-methods", auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - return r.json() - - def get_coinbase_accounts(self): - r = requests.get(self.url + "/coinbase-accounts", auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - return r.json() - - def create_report(self, report_type="", start_date="", end_date="", product_id="", account_id="", report_format="", - email=""): - payload = { - "type": report_type, - "start_date": start_date, - "end_date": end_date, - "product_id": product_id, - "account_id": account_id, - "format": report_format, - "email": email - } - r = requests.post(self.url + "/reports", data=json.dumps(payload), auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - return r.json() - - def get_report(self, report_id=""): - r = requests.get(self.url + "/reports/" + report_id, auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - return r.json() - - def get_trailing_volume(self): - r = requests.get(self.url + "/users/self/trailing-volume", auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - return r.json() - - def get_deposit_address(self, account_id): - r = requests.post(self.url + '/coinbase-accounts/{}/addresses'.format(account_id), auth=self.auth, timeout=self.timeout) - # r.raise_for_status() - return r.json() diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index ab35919d..00000000 --- a/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -addopts = --cov gdax/ --cov-report=term-missing -testpaths = tests \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..1d286529 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,17 @@ +atomicwrites==1.2.1 +attrs==18.2.0 +cbpro==1.1.4 +certifi==2018.10.15 +chardet==3.0.4 +idna==2.7 +more-itertools==4.3.0 +pluggy==0.8.0 +py==1.7.0 +pymongo==3.7.2 +pytest==4.0.1 +python-dateutil==2.7.5 +requests==2.20.1 +six==1.11.0 +sortedcontainers==2.1.0 +urllib3==1.24.2 +websocket-client==0.54.0 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 8a53a75c..00000000 --- a/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -sortedcontainers>=1.5.9 -requests==2.13.0 -six==1.10.0 -websocket-client==0.40.0 -pymongo==3.5.1 -pytest>=3.3.0 -pytest-cov>=2.5.0 \ No newline at end of file diff --git a/setup.py b/setup.py index 368ee131..47e1ca9c 100644 --- a/setup.py +++ b/setup.py @@ -3,30 +3,40 @@ from setuptools import setup, find_packages install_requires = [ - 'bintrees==2.0.7', - 'requests==2.13.0', - 'six==1.10.0', - 'websocket-client==0.40.0', - 'pymongo==3.5.1' + 'sortedcontainers>=1.5.9', + 'requests>=2.25.0', + 'six>=1.10.0', + 'websocket-client>=0.40.0', + 'pymongo>=3.5.1', ] tests_require = [ 'pytest', + 'python-dateutil>=2.7.5', ] +with open("README.md", "r") as fh: + long_description = fh.read() + setup( - name='gdax', - version='1.0.6', + name='cbpro', + version='1.1.4', author='Daniel Paquin', author_email='dpaq34@gmail.com', license='MIT', - url='https://github.com/danpaquin/gdax-python', + url='https://github.com/danpaquin/coinbasepro-python', packages=find_packages(), install_requires=install_requires, tests_require=tests_require, - description='The unofficial Python client for the GDAX API', - download_url='https://github.com/danpaquin/gdax-Python/archive/master.zip', - keywords=['gdax', 'gdax-api', 'orderbook', 'trade', 'bitcoin', 'ethereum', 'BTC', 'ETH', 'client', 'api', 'wrapper', 'exchange', 'crypto', 'currency', 'trading', 'trading-api', 'coinbase'], + extras_require={ + 'test': tests_require, + }, + description='The unofficial Python client for the Coinbase Pro API', + long_description=long_description, + long_description_content_type="text/markdown", + download_url='https://github.com/danpaquin/coinbasepro-python/archive/master.zip', + keywords=['gdax', 'gdax-api', 'orderbook', 'trade', 'bitcoin', 'ethereum', 'BTC', 'ETH', 'client', 'api', 'wrapper', + 'exchange', 'crypto', 'currency', 'trading', 'trading-api', 'coinbase', 'pro', 'prime', 'coinbasepro'], classifiers=[ 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', diff --git a/tests/api_config.json.example b/tests/api_config.json.example new file mode 100644 index 00000000..12d90097 --- /dev/null +++ b/tests/api_config.json.example @@ -0,0 +1,3 @@ +{"passphrase": "passhere", + "b64secret": "secrethere", + "key": "key here"} \ No newline at end of file diff --git a/tests/test_authenticated_client.py b/tests/test_authenticated_client.py new file mode 100644 index 00000000..36ae1beb --- /dev/null +++ b/tests/test_authenticated_client.py @@ -0,0 +1,208 @@ +import pytest +import json +import time +from itertools import islice +from cbpro.authenticated_client import AuthenticatedClient + + +@pytest.fixture(scope='module') +def dc(): + """Dummy client for testing.""" + return AuthenticatedClient('test', 'test', 'test') + + +@pytest.mark.usefixtures('dc') +class TestAuthenticatedClientSyntax(object): + def test_place_order_input_1(self, dc): + with pytest.raises(ValueError): + r = dc.place_order('BTC-USD', 'buy', 'market', + overdraft_enabled='true', funding_amount=10) + + def test_place_order_input_2(self, dc): + with pytest.raises(ValueError): + r = dc.place_order('BTC-USD', 'buy', 'limit', + cancel_after='123', time_in_force='ABC') + + def test_place_order_input_3(self, dc): + with pytest.raises(ValueError): + r = dc.place_order('BTC-USD', 'buy', 'limit', + post_only='true', time_in_force='FOK') + + def test_place_order_input_4(self, dc): + with pytest.raises(ValueError): + r = dc.place_order('BTC-USD', 'buy', 'market', + size=None, funds=None) + + def test_place_order_input_5(self, dc): + with pytest.raises(ValueError): + r = dc.place_order('BTC-USD', 'buy', 'market', + size=1, funds=1) + + +@pytest.fixture(scope='module') +def client(): + """Client that connects to sandbox API. Relies on authentication information + provided in api_config.json""" + with open('api_config.json.example') as file: + api_config = json.load(file) + c = AuthenticatedClient( + api_url='https://api-public.sandbox.pro.coinbase.com', **api_config) + + # Set up account with deposits and orders. Do this by depositing from + # the Coinbase USD wallet, which has a fixed value of > $10,000. + # + # Only deposit if the balance is below some nominal amount. The + # exchange seems to freak out if you run up your account balance. + coinbase_accounts = c.get_coinbase_accounts() + account_info = [x for x in coinbase_accounts + if x['name'] == 'USD Wallet'][0] + account_usd = account_info['id'] + if float(account_info['balance']) < 70000: + c.coinbase_deposit(10000, 'USD', account_usd) + # Place some orders to generate history + c.place_limit_order('BTC-USD', 'buy', 1, 0.01) + c.place_limit_order('BTC-USD', 'buy', 2, 0.01) + c.place_limit_order('BTC-USD', 'buy', 3, 0.01) + + return c + + +@pytest.mark.usefixtures('dc') +@pytest.mark.skip(reason="these test require authentication") +class TestAuthenticatedClient(object): + """Test the authenticated client by validating basic behavior from the + sandbox exchange.""" + def test_get_accounts(self, client): + r = client.get_accounts() + assert type(r) is list + assert 'currency' in r[0] + # Now get a single account + r = client.get_account(account_id=r[0]['id']) + assert type(r) is dict + assert 'currency' in r + + def test_account_history(self, client): + accounts = client.get_accounts() + account_usd = [x for x in accounts if x['currency'] == 'USD'][0]['id'] + r = list(islice(client.get_account_history(account_usd), 5)) + assert type(r) is list + assert 'amount' in r[0] + assert 'details' in r[0] + + # Now exercise the pagination abstraction. Setting limit to 1 means + # each record comes in a separate HTTP response. + history_gen = client.get_account_history(account_usd, limit=1) + r = list(islice(history_gen, 2)) + r2 = list(islice(history_gen, 2)) + assert r != r2 + # Now exercise the `before` parameter. + r3 = list(client.get_account_history(account_usd, before=r2[0]['id'])) + assert r3 == r + + def test_get_account_holds(self, client): + accounts = client.get_accounts() + account_usd = [x for x in accounts if x['currency'] == 'USD'][0]['id'] + r = list(client.get_account_holds(account_usd)) + assert type(r) is list + assert 'type' in r[0] + assert 'ref' in r[0] + + def test_convert_stablecoin(self, client): + r = client.convert_stablecoin('10.0', 'USD', 'USDC') + assert type(r) is dict + assert 'id' in r + assert r['amount'] == '10.00000000' + assert r['from'] == 'USD' + assert r['to'] == 'USDC' + + def test_place_order(self, client): + r = client.place_order('BTC-USD', 'buy', 'limit', + price=0.62, size=0.0144) + assert type(r) is dict + assert r['stp'] == 'dc' + + def test_place_limit_order(self, client): + r = client.place_limit_order('BTC-USD', 'buy', 4.43, 0.01232) + assert type(r) is dict + assert 'executed_value' in r + assert not r['post_only'] + client.cancel_order(r['id']) + + def test_place_market_order(self, client): + r = client.place_market_order('BTC-USD', 'buy', size=0.01) + assert 'status' in r + assert r['type'] == 'market' + client.cancel_order(r['id']) + + # This one probably won't go through + r = client.place_market_order('BTC-USD', 'buy', funds=100000) + assert type(r) is dict + + @pytest.mark.parametrize('stop_type', ['entry', 'loss']) + def test_place_stop_order(self, client, stop_type): + client.cancel_all() + r = client.place_stop_order('BTC-USD', stop_type, 100, 0.01) + assert type(r) is dict + assert r['stop'] == stop_type + assert r['stop_price'] == '100' + assert r['type'] == 'limit' + client.cancel_order(r['id']) + + def test_place_invalid_stop_order(self, client): + client.cancel_all() + with pytest.raises(ValueError): + client.place_stop_order('BTC-USD', 'fake_stop_type', 5.65, 0.01) + + def test_cancel_order(self, client): + r = client.place_limit_order('BTC-USD', 'buy', 4.43, 0.01232) + time.sleep(0.2) + r2 = client.cancel_order(r['id']) + assert r2[0] == r['id'] + + def test_cancel_all(self, client): + r = client.cancel_all() + assert type(r) is list + + def test_get_order(self, client): + r = client.place_limit_order('BTC-USD', 'buy', 4.43, 0.01232) + time.sleep(0.2) + r2 = client.get_order(r['id']) + assert r2['id'] == r['id'] + + def test_get_orders(self, client): + r = list(islice(client.get_orders(), 10)) + assert type(r) is list + assert 'created_at' in r[0] + + def test_get_fills(self, client): + r = list(islice(client.get_orders(), 10)) + assert type(r) is list + assert 'fill_fees' in r[0] + + def test_get_fundings(self, client): + r = list(islice(client.get_fundings(), 10)) + assert type(r) is list + + def test_repay_funding(self, client): + # This request gets denied + r = client.repay_funding(2.1, 'USD') + + def test_get_position(self, client): + r = client.get_position() + assert 'accounts' in r + + def test_get_payment_methods(self, client): + r = client.get_payment_methods() + assert type(r) is list + + def test_get_coinbase_accounts(self, client): + r = client.get_coinbase_accounts() + assert type(r) is list + + def test_get_trailing_volume(self, client): + r = client.get_trailing_volume() + assert type(r) is list + + def test_get_fees(self, client): + r = client.get_fees() + assert type(r) is dict diff --git a/tests/test_public_client.py b/tests/test_public_client.py index 4e9fd441..10d2d1f7 100644 --- a/tests/test_public_client.py +++ b/tests/test_public_client.py @@ -1,13 +1,14 @@ import pytest -import gdax import time +from itertools import islice import datetime from dateutil.relativedelta import relativedelta +from cbpro.public_client import PublicClient @pytest.fixture(scope='module') def client(): - return gdax.PublicClient() + return PublicClient() @pytest.mark.usefixtures('client') @@ -44,7 +45,7 @@ def test_get_product_ticker(self, client): assert 'trade_id' in r def test_get_product_trades(self, client): - r = client.get_product_trades('BTC-USD') + r = list(islice(client.get_product_trades('BTC-USD'), 200)) assert type(r) is list assert 'trade_id' in r[0] @@ -52,10 +53,12 @@ def test_get_product_trades(self, client): @pytest.mark.parametrize('start,end,granularity', [(current_time - relativedelta(months=1), - current_time, 10000)]) + current_time, 21600)]) def test_get_historic_rates(self, client, start, end, granularity): r = client.get_product_historic_rates('BTC-USD', start=start, end=end, granularity=granularity) assert type(r) is list + for ticker in r: + assert( all( [type(x) in (int, float) for x in ticker ] ) ) def test_get_product_24hr_stats(self, client): r = client.get_product_24hr_stats('BTC-USD') diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..e6ca111b --- /dev/null +++ b/tox.ini @@ -0,0 +1,10 @@ +[tox] +envlist = py27, py35, py36 + +[testenv] +setenv = PYTHONPATH = . +deps = + -rrequirements-dev.txt +commands= + python -m pytest -m "not xfail" {posargs: "{toxinidir}/cbpro/tests" --cov-config="{toxinidir}/tox.ini" --cov=cbpro} + python -m pytest -m "xfail" {posargs: "{toxinidir}/cbpro/tests"