From 79f99f25f9a3c40693a10947435928f19fd83dc8 Mon Sep 17 00:00:00 2001 From: acontry Date: Tue, 13 Jun 2017 22:28:56 -0700 Subject: [PATCH 001/101] Clean up PublicClient. Add doctrings + tests. Drop the mixed keyword/dict interface in favor of keywords only. Add docstrings based on Google format. --- README.md | 119 +++++++++++------ gdax/public_client.py | 247 ++++++++++++++++++++++++++++++------ setup.py | 5 + tests/test_public_client.py | 52 ++++++++ 4 files changed, 345 insertions(+), 78 deletions(-) create mode 100644 tests/test_public_client.py diff --git a/README.md b/README.md index 3c44787f..1c85c98f 100644 --- a/README.md +++ b/README.md @@ -2,38 +2,49 @@ The Python client for the [GDAX API](https://docs.gdax.com/) (formerly known as the Coinbase Exchange API) ##### Provided under MIT License by Daniel Paquin. -*Note: this library may be subtly broken or buggy. The code is released under the MIT License – please take the following message to heart:* -> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*Note: this library may be subtly broken or buggy. The code is released under +the MIT License – please take the following message to heart:* +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ## Benefits - A simple to use python wrapper for both public and authenticated endpoints. -- In about 10 minutes, you could be programmatically trading on one of the 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. +- In about 10 minutes, you could be programmatically trading on one of the +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. ## Under Development -- Test Scripts **on dev branch** -- Additional Functionality for *WebsocketClient*, including a real-time order book +- Unit testing +- Additional Functionality for *WebsocketClient*, including a real-time order +book - FIX API Client **Looking for support** ## Getting Started -This README is documentation on the syntax of the python client presented in this repository. **In order to use this wrapper to its full potential, you must familiarize yourself with the official GDAX documentation.** +This README is documentation on the syntax of the python client presented in +this repository. **In order to use this wrapper to its full potential, you must +familiarize yourself with the official GDAX documentation.** - https://docs.gdax.com/ - You may manually install the project or use ```pip```: ```python -pip install GDAX +pip install gdax ``` ### Public Client -Only some endpoints in the API are available to everyone. The public endpoints can be reached using ```PublicClient``` +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() -# Set a default product -public_client = gdax.PublicClient(product_id="ETH-USD") ``` ### PublicClient Methods @@ -45,37 +56,33 @@ public_client.get_products() - [getProductOrderBook](https://docs.gdax.com/#get-product-order-book) ```python # Get the order book at the default level. -public_client.get_product_order_book() +public_client.get_product_order_book(product_id='BTC-USD') # Get the order book at a specific level. -public_client.get_product_order_book(level=1) +public_client.get_product_order_book(product_id='BTC-USD', level=3) ``` - [getProductTicker](https://docs.gdax.com/#get-product-ticker) ```python -# Get the product ticker for the default product. -public_client.get_product_ticker() # Get the product ticker for a specific product. -public_client.get_product_ticker(product="ETH-USD") +public_client.get_product_ticker(product_id='ETH-USD') ``` - [getProductTrades](https://docs.gdax.com/#get-trades) ```python -# Get the product trades for the default product. -public_client.get_product_trades() # Get the product trades for a specific product. -public_client.get_product_trades(product="ETH-USD") +public_client.get_product_trades(product_id='ETH-USD') ``` - [getProductHistoricRates](https://docs.gdax.com/#get-historic-rates) ```python -public_client.get_product_historic_rates() +public_client.get_product_historic_rates(product_id='ETH-USD') # To include other parameters, see official documentation: -public_client.get_product_historic_rates(granularity=3000) +public_client.get_product_historic_rates('ETH-USD', granularity=3000) ``` - [getProduct24HrStates](https://docs.gdax.com/#get-24hr-stats) ```python -public_client.get_product_24hr_stats() +public_client.get_product_24hr_stats('ETH-USD') ``` - [getCurrencies](https://docs.gdax.com/#get-currencies) @@ -88,8 +95,9 @@ public_client.get_currencies() public_client.get_time() ``` -#### *In Development* JSON Parsing -Only available for the `PublicClient`, you may pass any function above raw JSON data. This may be useful for some applications of the project and should not hinder performance, but we are looking into this. *Do you love or hate this? Please share your thoughts within the issue tab!* +#### Function Call Methods +The API presents a consistent interface for parameters, which may be specified +in different ways. - Both of these calls send the same request: ```python @@ -98,12 +106,11 @@ public_client = gdax.PublicClient() method1 = public_client.get_product_historic_rates(granularity='3000') -params = { -'granularity': '3000' -} -method2 = public_client.get_product_historic_rates(params) +params = {'granularity': '3000'} +method2 = public_client.get_product_historic_rates(**params) -# Both methods will send the same request, but not always return the same data if run in series. +# Both methods will send the same request, but not always return the same data +# if run in series because of real-time data changes from GDAX. print (method1, method2) ``` @@ -123,19 +130,29 @@ integrate both into your script. import gdax auth_client = gdax.AuthenticatedClient(key, b64secret, passphrase) # Set a default product -auth_client = gdax.AuthenticatedClient(key, b64secret, passphrase, product_id="ETH-USD") +auth_client = gdax.AuthenticatedClient(key, b64secret, + passphrase, product_id="ETH-USD") # 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 = gdax.AuthenticatedClient(key, b64secret, + passphrase, api_url="https://api-public.sandbox.gdax.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* +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 prefered. +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. ### AuthenticatedClient Methods - [getAccounts](https://docs.gdax.com/#list-accounts) @@ -240,11 +257,16 @@ wsClient.close() ``` ### WebsocketClient Methods -The ```WebsocketClient``` subscribes in a separate thread upon initialization. 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 inital 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. +The ```WebsocketClient``` subscribes in a separate thread upon initialization. +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. + +- on_open - called once, *immediately before* the socket connection is made, this +is where you want to add inital parameters. +- on_message - called once for every message that arrives and accepts one +argument that contains the message of dict type. +- 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 @@ -255,7 +277,7 @@ class myWebsocketClient(gdax.WebsocketClient): self.message_count = 0 print("Lets count the messages!") def on_message(self, msg): - self.MessageCount += 1 + self.Message_count += 1 if 'price' in msg and 'type' in msg: print ("Message type:", msg["type"], "\t@ {}.3f".format(float(msg["price"]))) def on_close(self): @@ -271,7 +293,9 @@ wsClient.close() ``` ### 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 improvements. +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 improvements. ```python import gdax, time @@ -281,6 +305,16 @@ time.sleep(10) order_book.close() ``` +### 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 *0.3* **Current PyPI release** - Added crypto and LTC deposit & withdraw (undocumented). @@ -293,7 +327,8 @@ order_book.close() - Added additional API functionality such as cancelAll() and ETH withdrawal. *0.2.1* -- Allowed ```WebsocketClient``` to operate intuitively and restructured example workflow. +- Allowed ```WebsocketClient``` to operate intuitively and restructured +example workflow. *0.2.0* - Renamed project to GDAX-Python diff --git a/gdax/public_client.py b/gdax/public_client.py index 5742b9d8..ecbc7880 100644 --- a/gdax/public_client.py +++ b/gdax/public_client.py @@ -8,69 +8,244 @@ class PublicClient(object): - def __init__(self, api_url="https://api.gdax.com", product_id="BTC-USD"): - self.url = api_url.rstrip("/") - self.product_id = product_id + """GDAX 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. + + """ + + def __init__(self, api_url='https://api.gdax.com'): + """Create GDAX API public client. + + Args: + api_url (Optional[str]): API URL. Defaults to GDAX API. + + """ + self.url = api_url.rstrip('/') def get_products(self): + """Get a list of available currency pairs for trading. + + Returns: + list: Info about all currency pairs. Example:: + [ + { + "id": "BTC-USD", + "display_name": "BTC/USD", + "base_currency": "BTC", + "quote_currency": "USD", + "base_min_size": "0.01", + "base_max_size": "10000.00", + "quote_increment": "0.01" + } + ] + + """ r = requests.get(self.url + '/products') # r.raise_for_status() return r.json() - def get_product_order_book(self, json=None, level=2, product=''): - if type(json) is dict: - if "product" in json: - product = json["product"] - if "level" in json: - level = json['level'] - r = requests.get(self.url + '/products/{}/book?level={}'.format(product or self.product_id, str(level))) + def get_product_order_book(self, product_id, level=1): + """Get a list of open orders for a product. + + The amount of detail shown can be customized with the `level` + parameter: + * 1: Only the best bid and ask + * 2: Top 50 bids and asks (aggregated) + * 3: Full order book (non aggregated) + + Level 1 and Level 2 are recommended for polling. For the most + up-to-date data, consider using the websocket stream. + + **Caution**: Level 3 is only recommended for users wishing to + maintain a full real-time order book using the websocket + stream. Abuse of Level 3 via polling will cause your access to + be limited or blocked. + + Args: + product_id (str): Product + level (Optional[int]): Order book level (1, 2, or 3). + Default is 1. + + Returns: + dict: Order book. Example for level 1:: + { + "sequence": "3", + "bids": [ + [ price, size, num-orders ], + ], + "asks": [ + [ price, size, num-orders ], + ] + } + + """ + params = {'level': level} + r = requests.get(self.url + '/products/{}/book' + .format(product_id), params=params) # r.raise_for_status() return r.json() - def get_product_ticker(self, json=None, product=''): - if type(json) is dict: - if "product" in json: - product = json["product"] - r = requests.get(self.url + '/products/{}/ticker'.format(product or self.product_id)) + def get_product_ticker(self, product_id): + """Snapshot about the last trade (tick), best bid/ask and 24h volume. + + **Caution**: Polling is discouraged in favor of connecting via + the websocket stream and listening for match messages. + + Args: + product_id (str): Product + + Returns: + dict: Ticker info. Example:: + { + "trade_id": 4729088, + "price": "333.99", + "size": "0.193", + "bid": "333.98", + "ask": "333.99", + "volume": "5957.11914015", + "time": "2015-11-14T20:46:03.511254Z" + } + + """ + r = requests.get(self.url + '/products/{}/ticker' + .format(product_id)) # r.raise_for_status() return r.json() - def get_product_trades(self, json=None, product=''): - if type(json) is dict: - if "product" in json: - product = json["product"] - r = requests.get(self.url + '/products/{}/trades'.format(product or self.product_id)) + def get_product_trades(self, product_id): + """List the latest trades for a product. + + Args: + product_id (str): Product + + Returns: + list: Latest trades. Example:: + [{ + "time": "2014-11-07T22:19:28.578544Z", + "trade_id": 74, + "price": "10.00000000", + "size": "0.01000000", + "side": "buy" + }, { + "time": "2014-11-07T01:08:43.642366Z", + "trade_id": 73, + "price": "100.00000000", + "size": "0.01000000", + "side": "sell" + }] + + """ + r = requests.get(self.url + '/products/{}/trades'.format(product_id)) # r.raise_for_status() return r.json() - def get_product_historic_rates(self, json=None, product='', start='', end='', granularity=''): - payload = {} - if type(json) is dict: - if "product" in json: - product = json["product"] - payload = json - else: - payload["start"] = start - payload["end"] = end - payload["granularity"] = granularity - r = requests.get(self.url + '/products/{}/candles'.format(product or self.product_id), params=payload) + def get_product_historic_rates(self, product_id, start=None, end=None, + granularity=None): + """Historic rates for a product. + + Rates are returned in grouped buckets based on requested + `granularity`. If start, end, and granularity aren't provided, + the exchange will assume some (currently unknown) default values. + + Historical rate data may be incomplete. No data is published for + intervals where there are no ticks. + + **Caution**: Historical rates should not be polled frequently. + If you need real-time information, use the trade and book + endpoints along with the websocket feed. + + The maximum number of data points for a single request is 200 + candles. If your selection of start/end time and granularity + will result in more than 200 data points, your request will be + rejected. If you wish to retrieve fine granularity data over a + larger time range, you will need to make multiple requests with + new start/end ranges. + + Args: + 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 + + Returns: + list: Historic candle data. Example:: + [ + [ time, low, high, open, close, volume ], + [ 1415398768, 0.32, 4.2, 0.35, 4.2, 12.3 ], + ... + ] + + """ + params = {} + if start is not None: + params['start'] = start + if end is not None: + params['end'] = end + if granularity is not None: + params['granularity'] = granularity + r = requests.get(self.url + '/products/{}/candles' + .format(product_id), params=params) # r.raise_for_status() return r.json() - def get_product_24hr_stats(self, json=None, product=''): - if type(json) is dict: - if "product" in json: - product = json["product"] - r = requests.get(self.url + '/products/{}/stats'.format(product or self.product_id)) + def get_product_24hr_stats(self, product_id): + """Get 24 hr stats for the product. + + Args: + product_id (str): Product + + Returns: + dict: 24 hour stats. Volume is in base currency units. + Open, high, low are in quote currency units. Example:: + { + "open": "34.19000000", + "high": "95.70000000", + "low": "7.06000000", + "volume": "2.41000000" + } + + """ + r = requests.get(self.url + '/products/{}/stats'.format(product_id)) # r.raise_for_status() return r.json() def get_currencies(self): + """List known currencies. + + Returns: + list: List of currencies. Example:: + [{ + "id": "BTC", + "name": "Bitcoin", + "min_size": "0.00000001" + }, { + "id": "USD", + "name": "United States Dollar", + "min_size": "0.01000000" + }] + + """ r = requests.get(self.url + '/currencies') # r.raise_for_status() return r.json() def get_time(self): + """Get the API server time. + + Returns: + dict: Server time in ISO and epoch format (decimal seconds + since Unix epoch). Example:: + { + "iso": "2015-01-07T23:47:25.201Z", + "epoch": 1420674445.201 + } + + """ r = requests.get(self.url + '/time') # r.raise_for_status() return r.json() diff --git a/setup.py b/setup.py index 1d028f5b..3372d753 100644 --- a/setup.py +++ b/setup.py @@ -9,6 +9,10 @@ 'websocket-client==0.40.0', ] +tests_require = [ + 'pytest', + ] + setup( name='gdax', version='0.3.1', @@ -18,6 +22,7 @@ url='https://github.com/danpaquin/GDAX-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'], diff --git a/tests/test_public_client.py b/tests/test_public_client.py new file mode 100644 index 00000000..fd4850f3 --- /dev/null +++ b/tests/test_public_client.py @@ -0,0 +1,52 @@ +import pytest +import gdax + + +@pytest.fixture(scope='module') +def client(): + return gdax.PublicClient() + + +@pytest.mark.usefixtures('client') +class TestPublicClient(object): + def test_get_products(self, client): + r = client.get_products() + assert type(r) is list + + def test_get_product_order_book(self, client): + r = client.get_product_order_book() + assert type(r) is dict + r = client.get_product_order_book(level=2) + assert type(r) is dict + assert 'asks' in r + assert 'bids' in r + + def test_get_product_ticker(self, client): + r = client.get_product_ticker() + assert type(r) is dict + assert 'ask' in r + assert 'trade_id' in r + + def test_get_product_trades(self, client): + r = client.get_product_trades() + assert type(r) is list + assert 'trade_id' in r[0] + + def test_get_historic_rates(self, client): + r = client.get_product_historic_rates() + assert type(r) is list + + def test_get_product_24hr_stats(self, client): + r = client.get_product_24hr_stats() + assert type(r) is dict + assert 'volume_30day' in r + + def test_get_currencies(self, client): + r = client.get_currencies() + assert type(r) is list + assert 'name' in r[0] + + def test_get_time(self, client): + r = client.get_time() + assert type(r) is dict + assert 'iso' in r From c36a7ef8c414af618f9b5492ab5cfb101b5de8cf Mon Sep 17 00:00:00 2001 From: acontry Date: Thu, 15 Jun 2017 00:37:59 -0700 Subject: [PATCH 002/101] Clean up AuthenticatedClient. --- gdax/authenticated_client.py | 497 +++++++++++++++-------------- tests/test_authenticated_client.py | 36 +++ 2 files changed, 293 insertions(+), 240 deletions(-) create mode 100644 tests/test_authenticated_client.py diff --git a/gdax/authenticated_client.py b/gdax/authenticated_client.py index 28892437..30e545c3 100644 --- a/gdax/authenticated_client.py +++ b/gdax/authenticated_client.py @@ -15,273 +15,289 @@ class AuthenticatedClient(PublicClient): - def __init__(self, key, b64secret, passphrase, api_url="https://api.gdax.com", product_id="BTC-USD"): - super(self.__class__, self).__init__(api_url, product_id) + def __init__(self, key, b64secret, passphrase, + api_url="https://api.gdax.com"): + super(self.__class__, self).__init__(api_url) self.auth = GdaxAuth(key, b64secret, passphrase) + self.session = requests.Session() def get_account(self, account_id): - r = requests.get(self.url + '/accounts/' + account_id, auth=self.auth) - # r.raise_for_status() - return r.json() + return self._send_message('get', '/accounts/' + account_id) 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) - # 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) - # 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) - # 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) - # 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) - return r.json() - - def sell(self, **kwargs): - kwargs["side"] = "sell" - r = requests.post(self.url + '/orders', - data=json.dumps(kwargs), - auth=self.auth) - return r.json() + def get_account_history(self, account_id, **kwargs): + endpoint = '/accounts/{}/ledger'.format(account_id) + return self._send_message('get', endpoint, params=kwargs)[0] + + def get_account_holds(self, account_id, **kwargs): + endpoint = '/accounts/{}/holds'.format(account_id) + return self._send_message('get', endpoint, params=kwargs)[0] + + def place_order(self, product_id, side, order_type, **kwargs): + # 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('tif') != '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('tif') 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 order_type == '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 place_limit_order(self, product_id, side, price, size, + client_oid=None, + stp=None, + tif=None, + cancel_after=None, + post_only=None, + overdraft_enabled=None, + funding_amount=None): + params = {'product_id': product_id, + 'side': side, + 'order_type': 'limit', + 'price': price, + 'size': size, + 'client_oid': client_oid, + 'stp': stp, + 'tif': tif, + '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, funds, + client_oid=None, + stp=None, + overdraft_enabled=None, + funding_amount=None): + 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, side, price, size, funds, + client_oid=None, + stp=None, + overdraft_enabled=None, + funding_amount=None): + params = {'product_id': product_id, + 'side': side, + 'price': price, + 'order_type': 'stop', + '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): - r = requests.delete(self.url + '/orders/' + order_id, auth=self.auth) - # r.raise_for_status() - return r.json() - - def cancel_all(self, data=None, product=''): - if type(data) is dict: - if "product" in data: - product = data["product"] - r = requests.delete(self.url + '/orders/', - data=json.dumps({'product_id': product or self.product_id}), auth=self.auth) - # r.raise_for_status() - return r.json() + return self._send_message('delete', '/orders/' + order_id) + + def cancel_all(self, product_id=None): + if product_id is not None: + params = {'product_id': product_id} + data = json.dumps(params) + else: + data = None + return self._send_message('delete', '/orders', data=data) def get_order(self, order_id): - r = requests.get(self.url + '/orders/' + order_id, auth=self.auth) - # r.raise_for_status() - return r.json() - - def get_orders(self): - result = [] - r = requests.get(self.url + '/orders/', auth=self.auth) - # r.raise_for_status() - result.append(r.json()) - if 'cb-after' in r.headers: - self.paginate_orders(result, r.headers['cb-after']) - return result - - def paginate_orders(self, result, after): - r = requests.get(self.url + '/orders?after={}'.format(str(after))) - # r.raise_for_status() - if r.json(): - result.append(r.json()) - if 'cb-after' in r.headers: - self.paginate_orders(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)) + return self._send_message('get', '/orders/' + order_id) + + def get_orders(self, **kwargs): + return self._send_message('get', '/orders', params=kwargs)[0] + + def get_fills(self, product_id=None, order_id=None, **kwargs): + params = {} if product_id: - url += "product_id={}&".format(product_id or self.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) - # 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)) + params['product_id'] = product_id if order_id: - url += "order_id={}&".format(str(order_id)) - if product_id: - url += "product_id={}&".format(product_id or self.product_id) - r = requests.get(url, auth=self.auth) - # 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) - # 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 + params['order_id'] = order_id + params.update(kwargs) + + # Return `after` param so client can access more recent fills on next + # call of get_fills if desired. + message, r = self._send_message('get', '/fills', params=params) + return r.headers['cb-after'], message + + def get_fundings(self, status=None, **kwargs): + params = {} + if status is not None: + params['status'] = status + params.update(kwargs) + return self._send_message('get', '/funding', params=params)[0] + + def repay_funding(self, amount, currency): + 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): + params = { + 'margin_profile_id': margin_profile_id, + 'type': transfer_type, + 'currency': currency, # example: USD + 'amount': amount } - r = requests.post(self.url + "/funding/repay", data=json.dumps(payload), auth=self.auth) - # 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) - # r.raise_for_status() - return r.json() + return self._send_message('post', '/profiles/margin-transfer', + data=json.dumps(params)) def get_position(self): - r = requests.get(self.url + "/position", auth=self.auth) - # r.raise_for_status() - return r.json() - - def close_position(self, repay_only=""): - payload = { - "repay_only": repay_only or False + return self._send_message('get', '/position')[0] + + def close_position(self, repay_only): + params = {'repay_only': repay_only} + return self._send_message('post', '/position/close', + data=json.dumps(params))[0] + + def deposit(self, amount, currency, payment_method_id): + params = { + 'amount': amount, + 'currency': currency, + 'payment_method_id': payment_method_id } - r = requests.post(self.url + "/position/close", data=json.dumps(payload), auth=self.auth) - # 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 + return self._send_message('post', '/deposits/payment-method', + data=json.dumps(params))[0] + + def coinbase_deposit(self, amount, currency, coinbase_account_id): + params = { + 'amount': amount, + 'currency': currency, + 'coinbase_account_id': coinbase_account_id } - r = requests.post(self.url + "/deposits/payment-method", data=json.dumps(payload), auth=self.auth) - # 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 + return self._send_message('post', '/deposits/coinbase-account', + data=json.dumps(params))[0] + + def withdraw(self, amount, currency, payment_method_id): + params = { + 'amount': amount, + 'currency': currency, + 'payment_method_id': payment_method_id } - r = requests.post(self.url + "/deposits/coinbase-account", data=json.dumps(payload), auth=self.auth) - # 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 + return self._send_message('post', '/withdrawals/payment-method', + data=json.dumps(params))[0] + + def coinbase_withdraw(self, amount, currency, coinbase_account_id): + params = { + 'amount': amount, + 'currency': currency, + 'coinbase_account_id': coinbase_account_id } - r = requests.post(self.url + "/withdrawals/payment-method", data=json.dumps(payload), auth=self.auth) - # 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 + return self._send_message('post', '/withdrawals/coinbase', + data=json.dumps(params))[0] + + def crypto_withdraw(self, amount, currency, crypto_address): + params = { + 'amount': amount, + 'currency': currency, + 'crypto_address': crypto_address } - r = requests.post(self.url + "/withdrawals/coinbase", data=json.dumps(payload), auth=self.auth) - # 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) - # r.raise_for_status() - return r.json() + return self._send_message('post', '/withdrawals/crypto', + data=json.dumps(params))[0] def get_payment_methods(self): - r = requests.get(self.url + "/payment-methods", auth=self.auth) - # r.raise_for_status() - return r.json() + return self._send_message('get', '/payment-methods')[0] def get_coinbase_accounts(self): - r = requests.get(self.url + "/coinbase-accounts", auth=self.auth) - # 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 + return self._send_message('get', '/coinbase-accounts')[0] + + def create_report(self, report_type, start_date, end_date, product_id=None, + account_id=None, report_format='pdf', email=None): + params = { + 'type': report_type, + 'start_date': start_date, + 'end_date': end_date, + 'format': report_format, } - r = requests.post(self.url + "/reports", data=json.dumps(payload), auth=self.auth) - # r.raise_for_status() - return r.json() + 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))[0] - def get_report(self, report_id=""): - r = requests.get(self.url + "/reports/" + report_id, auth=self.auth) - # r.raise_for_status() - return r.json() + def get_report(self, report_id): + return self._send_message('get', '/reports/' + report_id)[0] def get_trailing_volume(self): - r = requests.get(self.url + "/users/self/trailing-volume", auth=self.auth) - # r.raise_for_status() - return r.json() + return self._send_message('get', '/users/self/trailing-volume')[0] + + def _send_message(self, method, endpoint, params=None, data=None): + """Get a paginated response by making multiple http requests. + + 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: + list: Merged responses from paginated requests + requests.models.Response: Response object from last HTTP + response + + """ + if params is None: + params = {} + response_data = [] + url = self.url + endpoint + r = self.session.request(method, url, params=params, data=data, + auth=self.auth) + if r.json(): + response_data = r.json() + if method == 'get': + while 'cb-after' in r.headers: + params['after'] = r.headers['cb-after'] + r = self.session.get(url, params=params, auth=self.auth) + if r.json(): + response_data += r.json() + return response_data, r class GdaxAuth(AuthBase): @@ -293,13 +309,14 @@ def __init__(self, api_key, secret_key, passphrase): def __call__(self, request): timestamp = str(time.time()) - message = timestamp + request.method + request.path_url + (request.body or '') + message = timestamp + request.method + request.path_url + \ + (request.body or '') message = message.encode('ascii') hmac_key = base64.b64decode(self.secret_key) signature = hmac.new(hmac_key, message, hashlib.sha256) signature_b64 = base64.b64encode(signature.digest()) request.headers.update({ - 'Content-Type': 'Application/JSON', + 'Content-Type': 'Application/json', 'CB-ACCESS-SIGN': signature_b64, 'CB-ACCESS-TIMESTAMP': timestamp, 'CB-ACCESS-KEY': self.api_key, diff --git a/tests/test_authenticated_client.py b/tests/test_authenticated_client.py new file mode 100644 index 00000000..b9ebb1b7 --- /dev/null +++ b/tests/test_authenticated_client.py @@ -0,0 +1,36 @@ +import pytest +import gdax + + +@pytest.fixture(scope='module') +def dc(): + """Dummy client for testing.""" + return gdax.AuthenticatedClient('test', 'test', 'test') + + +@pytest.mark.usefixtures('dc') +class TestAuthenticatedClient(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', tif='ABC') + + def test_place_order_input_3(self, dc): + with pytest.raises(ValueError): + r = dc.place_order('BTC-USD', 'buy', 'limit', + post_only='true', tif='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) From c3732a1ac2949436ccab49a46c68ca3513513e9b Mon Sep 17 00:00:00 2001 From: dj Date: Sun, 18 Jun 2017 23:31:13 -0400 Subject: [PATCH 003/101] added most of the docstrings --- gdax/authenticated_client.py | 362 ++++++++++++++++++++++++++++++++++- 1 file changed, 361 insertions(+), 1 deletion(-) diff --git a/gdax/authenticated_client.py b/gdax/authenticated_client.py index 30e545c3..26e47824 100644 --- a/gdax/authenticated_client.py +++ b/gdax/authenticated_client.py @@ -15,23 +15,153 @@ class AuthenticatedClient(PublicClient): + """ Provides access to Private Endpoints on the GDAX API. + + All requests default to the live `api_url`: 'https://api.gdax.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 (GdaxAuth): Custom authentication handler for each request. + session (requests.Session): Persistent HTTP connection object. + """ def __init__(self, key, b64secret, passphrase, api_url="https://api.gdax.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 GDAX API. + """ super(self.__class__, self).__init__(api_url) self.auth = GdaxAuth(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" + }, + { + "id": "e316cb9a-0808-4fd7-8914-97829c1925de", + "currency": "USD", + "balance": "80.2301373066930000", + "available": "79.2266348066930000", + "hold": "1.0035025000000000", + "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 GDAX + * 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_message('get', endpoint, params=kwargs)[0] def get_account_holds(self, account_id, **kwargs): + """ 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: + list: Hold information for the provided 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_message('get', endpoint, params=kwargs)[0] @@ -64,7 +194,7 @@ def place_order(self, product_id, side, order_type, **kwargs): params = {'product_id': product_id, 'side': side, 'type': order_type - } + } params.update(kwargs) return self._send_message('post', '/orders', data=json.dumps(params)) @@ -130,9 +260,42 @@ def place_stop_order(self, product_id, side, price, size, funds, 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 ordrers 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} data = json.dumps(params) @@ -141,12 +304,131 @@ def cancel_all(self, product_id=None): return self._send_message('delete', '/orders', data=data) 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, **kwargs): + """ List your current open orders. + + 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: + kwargs (dict): usage below + * status: Limit list of orders to these statuses. + Passing 'all' returns orders of all statuses. + ** default: ['open', 'pending', 'active'] + * product_id: Only list orders for a specific product + + 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 + }, + { + ... + } + ] + """ return self._send_message('get', '/orders', params=kwargs)[0] def get_fills(self, product_id=None, order_id=None, **kwargs): + """ Get a list of recent fills. + + 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 (Optional[str]): Limit list to this product_id + order_id (Optional[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" + }, + { + ... + } + ] + """ params = {} if product_id: params['product_id'] = product_id @@ -160,6 +442,34 @@ def get_fills(self, product_id=None, order_id=None, **kwargs): return r.headers['cb-after'], message def get_fundings(self, status=None, **kwargs): + """ Every order placed with a margin profile that draws funding + will create a funding record. + + Args: + status (list/str): Limit funding records to these statuses. + ** Options: 'open', 'active', 'pending' + 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 @@ -167,6 +477,15 @@ def get_fundings(self, status=None, **kwargs): return self._send_message('get', '/funding', params=params)[0] 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: + ??? + """ params = { 'amount': amount, 'currency': currency # example: USD @@ -194,6 +513,25 @@ def close_position(self, repay_only): data=json.dumps(params))[0] 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 (int): The amount to depost. + 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, @@ -203,6 +541,28 @@ def deposit(self, amount, currency, payment_method_id): data=json.dumps(params))[0] 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 GDAX + trading accounts within your daily limits. Moving funds between + Coinbase and GDAX is instant and free. + + See AuthenticatedClient.get_coinbase_accounts() to receive + information regarding your coinbase_accounts. + + Args: + amount (int): The amount to depost. + 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, From 1577df80655db27578fc7cf0b0a2a12b65c76e9a Mon Sep 17 00:00:00 2001 From: acontry Date: Mon, 19 Jun 2017 01:42:35 -0700 Subject: [PATCH 004/101] Convert paginated requests to use generators --- gdax/authenticated_client.py | 181 +++++++++++++++++------------------ 1 file changed, 87 insertions(+), 94 deletions(-) diff --git a/gdax/authenticated_client.py b/gdax/authenticated_client.py index 26e47824..95601909 100644 --- a/gdax/authenticated_client.py +++ b/gdax/authenticated_client.py @@ -35,7 +35,7 @@ def __init__(self, key, b64secret, passphrase, passphrase (str): Passphrase chosen when setting up key. api_url (Optional[str]): API URL. Defaults to GDAX API. """ - super(self.__class__, self).__init__(api_url) + super(AuthenticatedClient, self).__init__(api_url) self.auth = GdaxAuth(key, b64secret, passphrase) self.session = requests.Session() @@ -79,12 +79,7 @@ def get_accounts(self): "profile_id": "75da88c5-05bf-4f54-bc85-5c775bd68254" }, { - "id": "e316cb9a-0808-4fd7-8914-97829c1925de", - "currency": "USD", - "balance": "80.2301373066930000", - "available": "79.2266348066930000", - "hold": "1.0035025000000000", - "profile_id": "75da88c5-05bf-4f54-bc85-5c775bd68254" + ... } ] @@ -123,11 +118,14 @@ def get_account_history(self, account_id, **kwargs): "trade_id": "74", "product_id": "BTC-USD" } + }, + { + ... } ] """ endpoint = '/accounts/{}/ledger'.format(account_id) - return self._send_message('get', endpoint, params=kwargs)[0] + return self._send_paginated_message(endpoint, params=kwargs) def get_account_holds(self, account_id, **kwargs): """ Holds are placed on an account for active orders or pending @@ -159,11 +157,14 @@ def get_account_holds(self, account_id, **kwargs): "amount": "4.23", "type": "order", "ref": "0a205de4-dd35-4370-a285-fe8fc375a273", + }, + { + ... } ] """ endpoint = '/accounts/{}/holds'.format(account_id) - return self._send_message('get', endpoint, params=kwargs)[0] + return self._send_paginated_message(endpoint, params=kwargs) def place_order(self, product_id, side, order_type, **kwargs): # Margin parameter checks @@ -193,8 +194,7 @@ def place_order(self, product_id, side, order_type, **kwargs): # Build params dict params = {'product_id': product_id, 'side': side, - 'type': order_type - } + 'type': order_type} params.update(kwargs) return self._send_message('post', '/orders', data=json.dumps(params)) @@ -284,7 +284,8 @@ def cancel_all(self, product_id=None): """ With best effort, cancel all open orders. Args: - product_id (Optional[str]): Only cancel ordrers for this product_id + product_id (Optional[str]): Only cancel orders for this + product_id Returns: list: A list of ids of the canceled orders. Example:: @@ -337,12 +338,12 @@ def get_order(self, order_id): """ return self._send_message('get', '/orders/' + order_id) - def get_orders(self, **kwargs): + def get_orders(self, product_id=None, status=None, **kwargs): """ List your current open orders. - 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. + 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 @@ -357,11 +358,14 @@ def get_orders(self, **kwargs): the current state of any open orders. Args: - kwargs (dict): usage below - * status: Limit list of orders to these statuses. - Passing 'all' returns orders of all statuses. - ** default: ['open', 'pending', 'active'] - * product_id: Only list orders for a specific product + 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:: @@ -388,7 +392,12 @@ def get_orders(self, **kwargs): } ] """ - return self._send_message('get', '/orders', params=kwargs)[0] + 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=kwargs) def get_fills(self, product_id=None, order_id=None, **kwargs): """ Get a list of recent fills. @@ -436,10 +445,7 @@ def get_fills(self, product_id=None, order_id=None, **kwargs): params['order_id'] = order_id params.update(kwargs) - # Return `after` param so client can access more recent fills on next - # call of get_fills if desired. - message, r = self._send_message('get', '/fills', params=params) - return r.headers['cb-after'], message + 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 @@ -447,7 +453,7 @@ def get_fundings(self, status=None, **kwargs): Args: status (list/str): Limit funding records to these statuses. - ** Options: 'open', 'active', 'pending' + ** Options: 'outstanding', 'settled', 'rejected' kwargs (dict): Additional HTTP request parameters. Returns: @@ -474,7 +480,7 @@ def get_fundings(self, status=None, **kwargs): if status is not None: params['status'] = status params.update(kwargs) - return self._send_message('get', '/funding', params=params)[0] + return self._send_paginated_message('/funding', params=params) def repay_funding(self, amount, currency): """ Repay funding. Repays the older funding records first. @@ -495,22 +501,20 @@ def repay_funding(self, amount, currency): def margin_transfer(self, margin_profile_id, transfer_type, currency, amount): - params = { - 'margin_profile_id': margin_profile_id, - 'type': transfer_type, - 'currency': currency, # example: USD - 'amount': amount - } + 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): - return self._send_message('get', '/position')[0] + return self._send_message('get', '/position') def close_position(self, repay_only): params = {'repay_only': repay_only} return self._send_message('post', '/position/close', - data=json.dumps(params))[0] + data=json.dumps(params)) def deposit(self, amount, currency, payment_method_id): """ Deposit funds from a payment method. @@ -519,7 +523,7 @@ def deposit(self, amount, currency, payment_method_id): information regarding payment methods. Args: - amount (int): The amount to depost. + amount (int): The amount to deposit. currency (str): The type of currency. payment_method_id (str): ID of the payment method. @@ -532,13 +536,11 @@ def deposit(self, amount, currency, payment_method_id): "payout_at": "2016-08-20T00:31:09Z" } """ - params = { - 'amount': amount, - 'currency': currency, - 'payment_method_id': payment_method_id - } + params = {'amount': amount, + 'currency': currency, + 'payment_method_id': payment_method_id} return self._send_message('post', '/deposits/payment-method', - data=json.dumps(params))[0] + data=json.dumps(params)) def coinbase_deposit(self, amount, currency, coinbase_account_id): """ Deposit funds from a coinbase account. @@ -551,7 +553,7 @@ def coinbase_deposit(self, amount, currency, coinbase_account_id): information regarding your coinbase_accounts. Args: - amount (int): The amount to depost. + amount (int): The amount to deposit. currency (str): The type of currency. coinbase_account_id (str): ID of the coinbase account. @@ -563,55 +565,45 @@ def coinbase_deposit(self, amount, currency, coinbase_account_id): "currency": "BTC", } """ - params = { - 'amount': amount, - 'currency': currency, - 'coinbase_account_id': coinbase_account_id - } + params = {'amount': amount, + 'currency': currency, + 'coinbase_account_id': coinbase_account_id} return self._send_message('post', '/deposits/coinbase-account', - data=json.dumps(params))[0] + data=json.dumps(params)) def withdraw(self, amount, currency, payment_method_id): - params = { - 'amount': amount, - 'currency': currency, - 'payment_method_id': payment_method_id - } + params = {'amount': amount, + 'currency': currency, + 'payment_method_id': payment_method_id} return self._send_message('post', '/withdrawals/payment-method', - data=json.dumps(params))[0] + data=json.dumps(params)) def coinbase_withdraw(self, amount, currency, coinbase_account_id): - params = { - 'amount': amount, - 'currency': currency, - 'coinbase_account_id': coinbase_account_id - } + params = {'amount': amount, + 'currency': currency, + 'coinbase_account_id': coinbase_account_id} return self._send_message('post', '/withdrawals/coinbase', - data=json.dumps(params))[0] + data=json.dumps(params)) def crypto_withdraw(self, amount, currency, crypto_address): - params = { - 'amount': amount, - 'currency': currency, - 'crypto_address': crypto_address - } + params = {'amount': amount, + 'currency': currency, + 'crypto_address': crypto_address} return self._send_message('post', '/withdrawals/crypto', - data=json.dumps(params))[0] + data=json.dumps(params)) def get_payment_methods(self): - return self._send_message('get', '/payment-methods')[0] + return self._send_message('get', '/payment-methods') def get_coinbase_accounts(self): - return self._send_message('get', '/coinbase-accounts')[0] + 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): - params = { - 'type': report_type, - 'start_date': start_date, - 'end_date': end_date, - 'format': report_format, - } + 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: @@ -620,16 +612,16 @@ def create_report(self, report_type, start_date, end_date, product_id=None, params['email'] = email return self._send_message('post', '/reports', - data=json.dumps(params))[0] + data=json.dumps(params)) def get_report(self, report_id): - return self._send_message('get', '/reports/' + report_id)[0] + return self._send_message('get', '/reports/' + report_id) def get_trailing_volume(self): - return self._send_message('get', '/users/self/trailing-volume')[0] + return self._send_message('get', '/users/self/trailing-volume') def _send_message(self, method, endpoint, params=None, data=None): - """Get a paginated response by making multiple http requests. + """Send API request. Args: method (str): HTTP method (get, post, delete, etc.) @@ -638,26 +630,27 @@ def _send_message(self, method, endpoint, params=None, data=None): data (Optional[str]): JSON-encoded string payload for POST Returns: - list: Merged responses from paginated requests - requests.models.Response: Response object from last HTTP - response + dict/list: JSON response """ - if params is None: - params = {} - response_data = [] url = self.url + endpoint r = self.session.request(method, url, params=params, data=data, auth=self.auth) - if r.json(): - response_data = r.json() - if method == 'get': - while 'cb-after' in r.headers: + return r.json() + + def _send_paginated_message(self, endpoint, params=None): + url = self.url + endpoint + while True: + r = self.session.get(url, params=params, auth=self.auth) + 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 not r.headers.get('cb-after'): + break + else: params['after'] = r.headers['cb-after'] - r = self.session.get(url, params=params, auth=self.auth) - if r.json(): - response_data += r.json() - return response_data, r class GdaxAuth(AuthBase): From 373a690571f0e12e0010a91d1681b284cfdce502 Mon Sep 17 00:00:00 2001 From: DJ Strasser Date: Thu, 22 Jun 2017 15:59:14 -0400 Subject: [PATCH 005/101] Change 'kwargs' -> 'params' Was just looking over your last commit and saw this small mistake, great work! I like the new generator approach to pagination. --- gdax/authenticated_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gdax/authenticated_client.py b/gdax/authenticated_client.py index 95601909..4dfe836a 100644 --- a/gdax/authenticated_client.py +++ b/gdax/authenticated_client.py @@ -397,7 +397,7 @@ def get_orders(self, product_id=None, status=None, **kwargs): params['product_id'] = product_id if status is not None: params['status'] = status - return self._send_paginated_message('/orders', params=kwargs) + 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. From 2327a1349cc948fcbd057f0eb6b245f45c077e56 Mon Sep 17 00:00:00 2001 From: acontry Date: Sat, 1 Jul 2017 23:44:13 -0700 Subject: [PATCH 006/101] Fix paginated generator, add docstrings and tests. --- gdax/authenticated_client.py | 374 +++++++++++++++++++++++++++-- tests/api_config.json.example | 3 + tests/test_authenticated_client.py | 153 +++++++++++- 3 files changed, 515 insertions(+), 15 deletions(-) create mode 100644 tests/api_config.json.example diff --git a/gdax/authenticated_client.py b/gdax/authenticated_client.py index 4dfe836a..f87ab13b 100644 --- a/gdax/authenticated_client.py +++ b/gdax/authenticated_client.py @@ -128,8 +128,13 @@ def get_account_history(self, account_id, **kwargs): return self._send_paginated_message(endpoint, params=kwargs) def get_account_holds(self, account_id, **kwargs): - """ Holds are placed on an account for active orders or pending - withdraw requests. + """ 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 @@ -147,7 +152,7 @@ def get_account_holds(self, account_id, **kwargs): kwargs (dict): Additional HTTP request parameters. Returns: - list: Hold information for the provided account. Example:: + generator(list): Hold information for the account. Example:: [ { "id": "82dcd140-c3c7-4507-8de4-2c529cd1a28f", @@ -162,14 +167,65 @@ def get_account_holds(self, account_id, **kwargs): ... } ] + """ endpoint = '/accounts/{}/holds'.format(account_id) return self._send_paginated_message(endpoint, params=kwargs) def place_order(self, product_id, side, order_type, **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', 'market', or 'stop') + **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. GDAX 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: + 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') @@ -177,7 +233,7 @@ def place_order(self, product_id, side, order_type, **kwargs): # Limit order checks if order_type == 'limit': if kwargs.get('cancel_after') is not None and \ - kwargs.get('tif') != 'GTT': + kwargs.get('tif') != '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('tif') in \ @@ -201,11 +257,43 @@ def place_order(self, product_id, side, order_type, **kwargs): def place_limit_order(self, product_id, side, price, size, client_oid=None, stp=None, - tif=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', @@ -213,7 +301,7 @@ def place_limit_order(self, product_id, side, price, size, 'size': size, 'client_oid': client_oid, 'stp': stp, - 'tif': tif, + 'time_in_force': time_in_force, 'cancel_after': cancel_after, 'post_only': post_only, 'overdraft_enabled': overdraft_enabled, @@ -222,11 +310,34 @@ def place_limit_order(self, product_id, side, price, size, return self.place_order(**params) - def place_market_order(self, product_id, side, size, funds, + 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', @@ -240,11 +351,35 @@ def place_market_order(self, product_id, side, size, funds, return self.place_order(**params) - def place_stop_order(self, product_id, side, price, size, funds, + def place_stop_order(self, product_id, side, 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') + side (str): Order side ('buy' or 'sell) + 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. + + """ params = {'product_id': product_id, 'side': side, 'price': price, @@ -277,6 +412,7 @@ def cancel_order(self, order_id): Returns: list: Containing the order_id of cancelled order. Example:: [ "c5ab5eae-76be-480e-8961-00792dc7e138" ] + """ return self._send_message('delete', '/orders/' + order_id) @@ -296,6 +432,7 @@ def cancel_all(self, product_id=None): "dfc5ae27-cadb-4c0c-beef-8994936fde8a", "34fecfbf-de33-4273-b2c6-baf8e8948be4" ] + """ if product_id is not None: params = {'product_id': product_id} @@ -335,12 +472,16 @@ def get_order(self, order_id): "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. @@ -391,6 +532,7 @@ def get_orders(self, product_id=None, status=None, **kwargs): ... } ] + """ params = kwargs if product_id is not None: @@ -402,6 +544,9 @@ def get_orders(self, product_id=None, status=None, **kwargs): def get_fills(self, product_id=None, order_id=None, **kwargs): """ Get a list of recent fills. + 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 @@ -437,6 +582,7 @@ def get_fills(self, product_id=None, order_id=None, **kwargs): ... } ] + """ params = {} if product_id: @@ -451,6 +597,9 @@ 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' @@ -475,6 +624,7 @@ def get_fundings(self, status=None, **kwargs): ... } ] + """ params = {} if status is not None: @@ -490,7 +640,8 @@ def repay_funding(self, amount, currency): currency (str): The currency, example USD Returns: - ??? + Not specified by GDAX. + """ params = { 'amount': amount, @@ -501,6 +652,34 @@ def repay_funding(self, amount, currency): 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 @@ -509,9 +688,24 @@ def margin_transfer(self, margin_profile_id, transfer_type, currency, 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 GDAX. + + Returns: + Undocumented + + """ params = {'repay_only': repay_only} return self._send_message('post', '/position/close', data=json.dumps(params)) @@ -523,7 +717,7 @@ def deposit(self, amount, currency, payment_method_id): information regarding payment methods. Args: - amount (int): The amount to deposit. + amount (Decmial): The amount to deposit. currency (str): The type of currency. payment_method_id (str): ID of the payment method. @@ -535,6 +729,7 @@ def deposit(self, amount, currency, payment_method_id): "currency": "USD", "payout_at": "2016-08-20T00:31:09Z" } + """ params = {'amount': amount, 'currency': currency, @@ -553,7 +748,7 @@ def coinbase_deposit(self, amount, currency, coinbase_account_id): information regarding your coinbase_accounts. Args: - amount (int): The amount to deposit. + amount (Decimal): The amount to deposit. currency (str): The type of currency. coinbase_account_id (str): ID of the coinbase account. @@ -564,6 +759,7 @@ def coinbase_deposit(self, amount, currency, coinbase_account_id): "amount": "10.00", "currency": "BTC", } + """ params = {'amount': amount, 'currency': currency, @@ -572,6 +768,26 @@ def coinbase_deposit(self, amount, currency, coinbase_account_id): 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} @@ -579,6 +795,29 @@ def withdraw(self, amount, currency, payment_method_id): 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 GDAX + trading accounts within your daily limits. Moving funds between + Coinbase and GDAX 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} @@ -586,6 +825,22 @@ def coinbase_withdraw(self, amount, currency, coinbase_account_id): 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} @@ -593,13 +848,58 @@ def crypto_withdraw(self, amount, currency, crypto_address): 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, @@ -615,9 +915,39 @@ def create_report(self, report_type, start_date, end_date, product_id=None, 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 _send_message(self, method, endpoint, params=None, data=None): @@ -639,6 +969,19 @@ def _send_message(self, method, endpoint, params=None, data=None): 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. + + Args: + endpoint (str): Endpoint (to be added to base URL) + params (Optional[dict]): HTTP request parameters + + Yields: + dict: API response objects + + """ url = self.url + endpoint while True: r = self.session.get(url, params=params, auth=self.auth) @@ -646,8 +989,11 @@ def _send_paginated_message(self, endpoint, params=None): for result in results: yield result # If there are no more pages, we're done. Otherwise update `after` - # param to get next page - if not r.headers.get('cb-after'): + # param to get next page. + # If this request included `before` don't get any more pages - the + # GDAX 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/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 index b9ebb1b7..96f034f7 100644 --- a/tests/test_authenticated_client.py +++ b/tests/test_authenticated_client.py @@ -1,4 +1,7 @@ import pytest +import json +import time +from itertools import islice import gdax @@ -9,7 +12,7 @@ def dc(): @pytest.mark.usefixtures('dc') -class TestAuthenticatedClient(object): +class TestAuthenticatedClientSyntax(object): def test_place_order_input_1(self, dc): with pytest.raises(ValueError): r = dc.place_order('BTC-USD', 'buy', 'market', @@ -34,3 +37,151 @@ 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') as file: + api_config = json.load(file) + c = gdax.AuthenticatedClient( + api_url='https://api-public.sandbox.gdax.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') +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_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 + + def test_place_stop_order(self, client): + client.cancel_all() + r = client.place_stop_order('BTC-USD', 'buy', 1, 0.01) + assert type(r) is dict + assert r['type'] == 'stop' + client.cancel_order(r['id']) + + 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 From 6d5a7f9150ebbbf0b2634a5eeb2f1c60fed01b41 Mon Sep 17 00:00:00 2001 From: acontry Date: Wed, 5 Jul 2017 21:18:02 -0700 Subject: [PATCH 007/101] Update README to explain updated authenticated API Also add docstring comments about paginated request paramters. --- README.md | 46 +++++++++++++++++++++++------------- gdax/authenticated_client.py | 9 +++++++ 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 46c04a29..45db556d 100644 --- a/README.md +++ b/README.md @@ -110,9 +110,6 @@ integrate both into your script. ```python import gdax auth_client = gdax.AuthenticatedClient(key, b64secret, passphrase) -# Set a default product -auth_client = gdax.AuthenticatedClient(key, b64secret, passphrase, - product_id="ETH-USD") # 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") @@ -120,20 +117,29 @@ auth_client = gdax.AuthenticatedClient(key, b64secret, passphrase, ### 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* +calls must be made to receive the full set of data. The GDAX 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 GDAX. ```python -request = auth_client.get_fills(limit=100) -request[0] # Page 1 always present -request[1] # Page 2+ present only if the data exists +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']) ``` -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. ### AuthenticatedClient Methods - [get_accounts](https://docs.gdax.com/#list-accounts) @@ -148,11 +154,13 @@ auth_client.get_account("7d0f7d8e-dd34-4d9c-a846-06f431c381ba") - [get_account_history](https://docs.gdax.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) ```python +# Returns generator: auth_client.get_account_holds("7d0f7d8e-dd34-4d9c-a846-06f431c381ba") ``` @@ -181,6 +189,7 @@ auth_client.cancel_all(product='BTC-USD') - [get_orders](https://docs.gdax.com/#list-orders) (paginated) ```python +# Returns generator: auth_client.get_orders() ``` @@ -191,6 +200,7 @@ auth_client.get_order("d50ec984-77a8-460a-b958-66f114b0de9b") - [get_fills](https://docs.gdax.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") @@ -276,8 +286,10 @@ while (wsClient.message_count < 500): 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 ``` diff --git a/gdax/authenticated_client.py b/gdax/authenticated_client.py index 2099444b..b3c79a50 100644 --- a/gdax/authenticated_client.py +++ b/gdax/authenticated_client.py @@ -974,6 +974,15 @@ def _send_paginated_message(self, endpoint, params=None): 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 From 5c9bc2b375a4687e3e6767cc9cb41ff0f94f4d62 Mon Sep 17 00:00:00 2001 From: acontry Date: Thu, 21 Sep 2017 23:32:37 -0700 Subject: [PATCH 008/101] Add back buy and sell methods --- gdax/authenticated_client.py | 44 ++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/gdax/authenticated_client.py b/gdax/authenticated_client.py index 57334c8e..f315bd44 100644 --- a/gdax/authenticated_client.py +++ b/gdax/authenticated_client.py @@ -254,6 +254,46 @@ def place_order(self, product_id, side, order_type, **kwargs): 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 GDAX-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 GDAX-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, @@ -931,7 +971,7 @@ def get_report(self, 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. + This is a cached value that's calculated every day at midnight UTC. Returns: list: 30-day trailing volumes. Example:: @@ -1006,7 +1046,7 @@ def _send_paginated_message(self, endpoint, params=None): break else: params['after'] = r.headers['cb-after'] - + class GdaxAuth(AuthBase): # Provided by gdax: https://docs.gdax.com/#signing-a-message From 5d1c7d122c3d9bdfde6ddf46b210a2cfae67df94 Mon Sep 17 00:00:00 2001 From: acontry Date: Fri, 22 Sep 2017 00:10:26 -0700 Subject: [PATCH 009/101] Move send_message wrappers to public client This move was made so get_product_trades can return a generator like all other paginated API endpoints. --- README.md | 26 ++++++++- gdax/authenticated_client.py | 57 ------------------- gdax/public_client.py | 103 ++++++++++++++++++++++++++--------- tests/test_public_client.py | 3 +- 4 files changed, 104 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index eccc72a0..495f5340 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,10 @@ public_client.get_product_order_book('BTC-USD', level=1) public_client.get_product_ticker(product_id='ETH-USD') ``` -- [get_product_trades](https://docs.gdax.com/#get-trades) +- [get_product_trades](https://docs.gdax.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') ``` @@ -169,14 +170,37 @@ auth_client.get_account_holds("7d0f7d8e-dd34-4d9c-a846-06f431c381ba") # 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', + side='buy', + price='200.00', + size='0.01') +``` - [cancel_order](https://docs.gdax.com/#cancel-an-order) ```python diff --git a/gdax/authenticated_client.py b/gdax/authenticated_client.py index f315bd44..74bab8aa 100644 --- a/gdax/authenticated_client.py +++ b/gdax/authenticated_client.py @@ -990,63 +990,6 @@ def get_trailing_volume(self): """ return self._send_message('get', '/users/self/trailing-volume') - 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 - - """ - 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 - # GDAX 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'] - class GdaxAuth(AuthBase): # Provided by gdax: https://docs.gdax.com/#signing-a-message diff --git a/gdax/public_client.py b/gdax/public_client.py index 91d4f5cf..21d6d3e1 100644 --- a/gdax/public_client.py +++ b/gdax/public_client.py @@ -26,6 +26,8 @@ def __init__(self, api_url='https://api.gdax.com'): """ self.url = api_url.rstrip('/') + self.auth = None + self.session = requests.Session() def get_products(self): """Get a list of available currency pairs for trading. @@ -45,9 +47,7 @@ def get_products(self): ] """ - r = requests.get(self.url + '/products', timeout=30) - # r.raise_for_status() - return r.json() + 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. @@ -85,10 +85,9 @@ def get_product_order_book(self, product_id, level=1): """ params = {'level': level} - r = requests.get(self.url + '/products/{}/book' - .format(product_id), params=params, timeout=30) - # r.raise_for_status() - return r.json() + 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. @@ -112,14 +111,15 @@ def get_product_ticker(self, product_id): } """ - r = requests.get(self.url + '/products/{}/ticker' - .format(product_id), timeout=30) - # r.raise_for_status() - return r.json() + return self._send_message('get', + '/products/{}/ticker'.format(product_id)) def get_product_trades(self, product_id): """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 @@ -140,9 +140,8 @@ def get_product_trades(self, product_id): }] """ - r = requests.get(self.url + '/products/{}/trades'.format(product_id), timeout=30) - # r.raise_for_status() - return r.json() + return self._send_paginated_message('/products/{}/trades' + .format(product_id)) def get_product_historic_rates(self, product_id, start=None, end=None, granularity=None): @@ -188,10 +187,8 @@ def get_product_historic_rates(self, product_id, start=None, end=None, params['end'] = end if granularity is not None: params['granularity'] = granularity - r = requests.get(self.url + '/products/{}/candles' - .format(product_id), params=params, timeout=30) - # r.raise_for_status() - return r.json() + return self._send_message('get', + '/products/{}/candles'.format(product_id)) def get_product_24hr_stats(self, product_id): """Get 24 hr stats for the product. @@ -210,9 +207,8 @@ def get_product_24hr_stats(self, product_id): } """ - r = requests.get(self.url + '/products/{}/stats'.format(product_id), timeout=30) - # r.raise_for_status() - return r.json() + return self._send_message('get', + '/products/{}/stats'.format(product_id)) def get_currencies(self): """List known currencies. @@ -230,9 +226,7 @@ def get_currencies(self): }] """ - r = requests.get(self.url + '/currencies', timeout=30) - # r.raise_for_status() - return r.json() + return self._send_message('get', '/currencies') def get_time(self): """Get the API server time. @@ -246,6 +240,63 @@ def get_time(self): } """ - r = requests.get(self.url + '/time', timeout=30) - # r.raise_for_status() + 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 + # GDAX 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/tests/test_public_client.py b/tests/test_public_client.py index 5da77c27..45f7ed5b 100644 --- a/tests/test_public_client.py +++ b/tests/test_public_client.py @@ -1,4 +1,5 @@ import pytest +from itertools import islice import gdax @@ -28,7 +29,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] From 2e1189edaefc4fbd86061c582f2a0140529bb684 Mon Sep 17 00:00:00 2001 From: kzielinski Date: Tue, 31 Oct 2017 21:41:44 -0500 Subject: [PATCH 010/101] ISSUE-128: fixed auth keys and encoding issues with websocket client --- gdax/websocket_client.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/gdax/websocket_client.py b/gdax/websocket_client.py index 69e23b66..4f05ad0f 100644 --- a/gdax/websocket_client.py +++ b/gdax/websocket_client.py @@ -15,10 +15,10 @@ from websocket import create_connection, WebSocketConnectionClosedException from pymongo import MongoClient -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): +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): self.url = url self.products = products self.channels = channels @@ -55,10 +55,9 @@ def _connect(self): self.url = self.url[:-1] if self.channels is None: - sub_params = {'type': 'subscribe', 'product_ids': self.products} + sub_params = {'type': 'subscribe', 'product_ids': self.products} else: - sub_params = {'type': 'subscribe', 'product_ids': self.products, 'channels': self.channels} - + sub_params = {'type': 'subscribe', 'product_ids': self.products, 'channels': self.channels} if self.auth: timestamp = str(time.time()) @@ -66,11 +65,11 @@ def _connect(self): 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()) - sub_params['signature'] = signature_b64 - sub_params['key'] = self.api_key - sub_params['passphrase'] = self.api_passphrase - sub_params['timestamp'] = timestamp + signature_b64 = base64.b64encode(signature.digest()).decode("utf-8") + sub_params['CB-ACCESS-SIGN'] = signature_b64 + sub_params['CB-ACCESS-KEY'] = self.api_key + sub_params['CB-ACCESS-PASSPHRASE'] = self.api_passphrase + sub_params['CB-ACCESS-TIMESTAMP'] = timestamp self.ws = create_connection(self.url) self.ws.send(json.dumps(sub_params)) @@ -81,7 +80,6 @@ def _connect(self): sub_params = {"type": "heartbeat", "on": False} self.ws.send(json.dumps(sub_params)) - def _listen(self): while not self.stop: try: @@ -123,7 +121,7 @@ def on_close(self): def on_message(self, msg): if self.should_print: print(msg) - if self.mongo_collection: # dump JSON to given mongo collection + if self.mongo_collection: # dump JSON to given mongo collection self.mongo_collection.insert_one(msg) def on_error(self, e, data=None): @@ -131,11 +129,13 @@ def on_error(self, e, data=None): self.stop print('{} - data: {}'.format(e, data)) + if __name__ == "__main__": import sys import gdax import time + class MyWebsocketClient(gdax.WebsocketClient): def on_open(self): self.url = "wss://ws-feed.gdax.com/" @@ -150,6 +150,7 @@ def on_message(self, msg): def on_close(self): print("-- Goodbye! --") + wsClient = MyWebsocketClient() wsClient.start() print(wsClient.url, wsClient.products) From d4c4521750c7613b6f2cbaf54ed05b5fae84c973 Mon Sep 17 00:00:00 2001 From: kzielinski Date: Wed, 1 Nov 2017 00:55:22 -0500 Subject: [PATCH 011/101] ISSUE-128: refactored to prevent duplicate code --- gdax/authenticated_client.py | 27 ++------------------------- gdax/gdax_auth.py | 34 ++++++++++++++++++++++++++++++++++ gdax/websocket_client.py | 10 ++-------- 3 files changed, 38 insertions(+), 33 deletions(-) create mode 100644 gdax/gdax_auth.py diff --git a/gdax/authenticated_client.py b/gdax/authenticated_client.py index 6e00e616..2898a772 100644 --- a/gdax/authenticated_client.py +++ b/gdax/authenticated_client.py @@ -12,6 +12,7 @@ import json from requests.auth import AuthBase from gdax.public_client import PublicClient +from gdax.gdax_auth import GdaxAuth class AuthenticatedClient(PublicClient): @@ -288,28 +289,4 @@ def get_report(self, report_id=""): def get_trailing_volume(self): r = requests.get(self.url + "/users/self/trailing-volume", auth=self.auth, timeout=30) # r.raise_for_status() - return r.json() - - -class GdaxAuth(AuthBase): - # Provided by gdax: https://docs.gdax.com/#signing-a-message - def __init__(self, api_key, secret_key, passphrase): - self.api_key = api_key - self.secret_key = secret_key - self.passphrase = passphrase - - def __call__(self, request): - timestamp = str(time.time()) - message = timestamp + request.method + request.path_url + (request.body or '') - message = message.encode('ascii') - hmac_key = base64.b64decode(self.secret_key) - signature = hmac.new(hmac_key, message, hashlib.sha256) - signature_b64 = base64.b64encode(signature.digest()) - request.headers.update({ - 'Content-Type': 'Application/JSON', - 'CB-ACCESS-SIGN': signature_b64, - 'CB-ACCESS-TIMESTAMP': timestamp, - 'CB-ACCESS-KEY': self.api_key, - 'CB-ACCESS-PASSPHRASE': self.passphrase - }) - return request + return r.json() \ No newline at end of file diff --git a/gdax/gdax_auth.py b/gdax/gdax_auth.py new file mode 100644 index 00000000..14757c3c --- /dev/null +++ b/gdax/gdax_auth.py @@ -0,0 +1,34 @@ +import hmac +import hashlib +import time +import base64 +from requests.auth import AuthBase + + +class GdaxAuth(AuthBase): + # Provided by gdax: https://docs.gdax.com/#signing-a-message + def __init__(self, api_key, secret_key, passphrase): + self.api_key = api_key + self.secret_key = secret_key + self.passphrase = passphrase + + def __call__(self, request): + timestamp = str(time.time()) + message = timestamp + request.method + request.path_url + (request.body or '') + request.headers.update(get_auth_headers(timestamp, message, self.api_key, self.secret_key, + self.passphrase)) + return request + + +def get_auth_headers(timestamp, message, api_key, secret_key, passphrase): + message = message.encode('ascii') + hmac_key = base64.b64decode(secret_key) + signature = hmac.new(hmac_key, message, hashlib.sha256) + signature_b64 = base64.b64encode(signature.digest()).decode('utf-8') + return { + 'Content-Type': 'Application/JSON', + 'CB-ACCESS-SIGN': signature_b64, + 'CB-ACCESS-TIMESTAMP': timestamp, + 'CB-ACCESS-KEY': api_key, + 'CB-ACCESS-PASSPHRASE': passphrase + } diff --git a/gdax/websocket_client.py b/gdax/websocket_client.py index 4f05ad0f..0c679e10 100644 --- a/gdax/websocket_client.py +++ b/gdax/websocket_client.py @@ -14,6 +14,7 @@ from threading import Thread from websocket import create_connection, WebSocketConnectionClosedException from pymongo import MongoClient +from gdax.gdax_auth import get_auth_headers class WebsocketClient(object): @@ -62,14 +63,7 @@ def _connect(self): if self.auth: timestamp = str(time.time()) message = timestamp + 'GET' + '/users/self' - 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") - sub_params['CB-ACCESS-SIGN'] = signature_b64 - sub_params['CB-ACCESS-KEY'] = self.api_key - sub_params['CB-ACCESS-PASSPHRASE'] = self.api_passphrase - sub_params['CB-ACCESS-TIMESTAMP'] = timestamp + sub_params.update(get_auth_headers(timestamp, message, self.api_key, self.api_secret, self.api_passphrase)) self.ws = create_connection(self.url) self.ws.send(json.dumps(sub_params)) From e0612e2eaa1f02ff7de2c44ecb9fa3396ec7f8d8 Mon Sep 17 00:00:00 2001 From: Hegusung Date: Tue, 21 Nov 2017 10:00:47 +0100 Subject: [PATCH 012/101] Support status param in get_orders --- gdax/authenticated_client.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/gdax/authenticated_client.py b/gdax/authenticated_client.py index 2898a772..5128a0ad 100644 --- a/gdax/authenticated_client.py +++ b/gdax/authenticated_client.py @@ -100,23 +100,32 @@ def get_order(self, order_id): # r.raise_for_status() return r.json() - def get_orders(self, product_id=''): + def get_orders(self, product_id='', status=[]): result = [] url = self.url + '/orders/' + params = {} if product_id: - url += "?product_id={}&".format(product_id) - r = requests.get(url, auth=self.auth, timeout=30) + params["product_id"] = product_id + if status: + params["status"] = status + r = requests.get(url, auth=self.auth, params=params, timeout=30) # r.raise_for_status() result.append(r.json()) if 'cb-after' in r.headers: - self.paginate_orders(product_id, result, r.headers['cb-after']) + self.paginate_orders(product_id, status, result, r.headers['cb-after']) return result - def paginate_orders(self, product_id, result, after): - url = self.url + '/orders?after={}&'.format(str(after)) + def paginate_orders(self, product_id, status, result, after): + url = self.url + '/orders' + + params = { + "after": str(after), + } if product_id: - url += "product_id={}&".format(product_id) - r = requests.get(url, auth=self.auth, timeout=30) + params["product_id"] = product_id + if status: + params["status"] = status + r = requests.get(url, auth=self.auth, params=params, timeout=30) # r.raise_for_status() if r.json(): result.append(r.json()) @@ -289,4 +298,4 @@ def get_report(self, report_id=""): def get_trailing_volume(self): r = requests.get(self.url + "/users/self/trailing-volume", auth=self.auth, timeout=30) # r.raise_for_status() - return r.json() \ No newline at end of file + return r.json() From c9cd0d1b768a1dc26841776eea1662909bd7a6bb Mon Sep 17 00:00:00 2001 From: redmoonshine <33815561+redmoonshine@users.noreply.github.com> Date: Fri, 24 Nov 2017 15:56:35 -0800 Subject: [PATCH 013/101] Stop should be true when on error --- gdax/websocket_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gdax/websocket_client.py b/gdax/websocket_client.py index 0c679e10..de468e7e 100644 --- a/gdax/websocket_client.py +++ b/gdax/websocket_client.py @@ -120,7 +120,7 @@ def on_message(self, msg): def on_error(self, e, data=None): self.error = e - self.stop + self.stop = True print('{} - data: {}'.format(e, data)) From 2e5c7104679a7e2616ec044baec669a2027ea313 Mon Sep 17 00:00:00 2001 From: John Lane Date: Wed, 29 Nov 2017 02:24:26 -0600 Subject: [PATCH 014/101] Improvements to gdax public client Consolidate all requests.get into single call Removing duplicate code on all the Public client gets. Get product order book should check for invalid levels Default to level 1 Add pytest-cov pytest-cov provides coverage reports Parametrize order book test Test get product order book with multiple levels Add wait between tests to avoid rate limits Parameterize historic rates test Add parameters for start, end and granularity --- gdax/public_client.py | 47 +++++++++++++++---------------------- pytest.ini | 3 +++ requirements.txt | 4 +++- tests/test_public_client.py | 29 ++++++++++++++++++----- 4 files changed, 48 insertions(+), 35 deletions(-) create mode 100644 pytest.ini diff --git a/gdax/public_client.py b/gdax/public_client.py index 91d4f5cf..305f67b0 100644 --- a/gdax/public_client.py +++ b/gdax/public_client.py @@ -27,6 +27,13 @@ def __init__(self, api_url='https://api.gdax.com'): """ self.url = api_url.rstrip('/') + def _get(self, path, params=None): + """Perform get request""" + + r = requests.get(self.url + path, params=params, timeout=30) + # r.raise_for_status() + return r.json() + def get_products(self): """Get a list of available currency pairs for trading. @@ -45,9 +52,7 @@ def get_products(self): ] """ - r = requests.get(self.url + '/products', timeout=30) - # r.raise_for_status() - return r.json() + return self._get('/products') def get_product_order_book(self, product_id, level=1): """Get a list of open orders for a product. @@ -84,11 +89,10 @@ def get_product_order_book(self, product_id, level=1): } """ - params = {'level': level} - r = requests.get(self.url + '/products/{}/book' - .format(product_id), params=params, timeout=30) - # r.raise_for_status() - return r.json() + + # 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}) def get_product_ticker(self, product_id): """Snapshot about the last trade (tick), best bid/ask and 24h volume. @@ -112,10 +116,7 @@ def get_product_ticker(self, product_id): } """ - r = requests.get(self.url + '/products/{}/ticker' - .format(product_id), timeout=30) - # r.raise_for_status() - return r.json() + return self._get('/products/{}/ticker'.format(str(product_id))) def get_product_trades(self, product_id): """List the latest trades for a product. @@ -140,9 +141,7 @@ def get_product_trades(self, product_id): }] """ - r = requests.get(self.url + '/products/{}/trades'.format(product_id), timeout=30) - # r.raise_for_status() - return r.json() + return self._get('/products/{}/trades'.format(str(product_id))) def get_product_historic_rates(self, product_id, start=None, end=None, granularity=None): @@ -188,10 +187,8 @@ def get_product_historic_rates(self, product_id, start=None, end=None, params['end'] = end if granularity is not None: params['granularity'] = granularity - r = requests.get(self.url + '/products/{}/candles' - .format(product_id), params=params, timeout=30) - # r.raise_for_status() - return r.json() + + return self._get('/products/{}/candles'.format(str(product_id)), params=params) def get_product_24hr_stats(self, product_id): """Get 24 hr stats for the product. @@ -210,9 +207,7 @@ def get_product_24hr_stats(self, product_id): } """ - r = requests.get(self.url + '/products/{}/stats'.format(product_id), timeout=30) - # r.raise_for_status() - return r.json() + return self._get('/products/{}/stats'.format(str(product_id))) def get_currencies(self): """List known currencies. @@ -230,9 +225,7 @@ def get_currencies(self): }] """ - r = requests.get(self.url + '/currencies', timeout=30) - # r.raise_for_status() - return r.json() + return self._get('/currencies') def get_time(self): """Get the API server time. @@ -246,6 +239,4 @@ def get_time(self): } """ - r = requests.get(self.url + '/time', timeout=30) - # r.raise_for_status() - return r.json() + return self._get('/time') diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..ab35919d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +addopts = --cov gdax/ --cov-report=term-missing +testpaths = tests \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2ef54de0..f7f58718 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,6 @@ bintrees==2.0.7 requests==2.13.0 six==1.10.0 websocket-client==0.40.0 -pymongo==3.5.1 \ No newline at end of file +pymongo==3.5.1 +pytest>=3.3.0 +pytest-cov>=2.5.0 \ No newline at end of file diff --git a/tests/test_public_client.py b/tests/test_public_client.py index 5da77c27..d33a1844 100644 --- a/tests/test_public_client.py +++ b/tests/test_public_client.py @@ -1,5 +1,6 @@ import pytest import gdax +import time @pytest.fixture(scope='module') @@ -9,18 +10,31 @@ def client(): @pytest.mark.usefixtures('client') class TestPublicClient(object): + + @staticmethod + def teardown_method(): + time.sleep(.25) # Avoid rate limit + def test_get_products(self, client): r = client.get_products() assert type(r) is list - def test_get_product_order_book(self, client): - r = client.get_product_order_book('BTC-USD') - assert type(r) is dict - r = client.get_product_order_book('BTC-USD', level=2) + @pytest.mark.parametrize('level', [1, 2, 3, None]) + def test_get_product_order_book(self, client, level): + r = client.get_product_order_book('BTC-USD', level=level) assert type(r) is dict assert 'asks' in r assert 'bids' in r + if level in (1, None) and (len(r['asks']) > 1 or len(r['bids']) > 1): + pytest.fail('Fail: Level 1 should only return the best ask and bid') + + if level is 2 and (len(r['asks']) > 50 or len(r['bids']) > 50): + pytest.fail('Fail: Level 2 should only return the top 50 asks and bids') + + if level is 2 and (len(r['asks']) < 50 or len(r['bids']) < 50): + pytest.fail('Fail: Level 3 should return the full order book') + def test_get_product_ticker(self, client): r = client.get_product_ticker('BTC-USD') assert type(r) is dict @@ -32,8 +46,11 @@ def test_get_product_trades(self, client): assert type(r) is list assert 'trade_id' in r[0] - def test_get_historic_rates(self, client): - r = client.get_product_historic_rates('BTC-USD') + @pytest.mark.parametrize('start', ('2017-11-01', None)) + @pytest.mark.parametrize('end', ('2017-11-30', None)) + @pytest.mark.parametrize('granularity', (3600, None)) + 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 def test_get_product_24hr_stats(self, client): From 3f1444dcf9c5881e62de4ddcd0e59468e6736452 Mon Sep 17 00:00:00 2001 From: Damian Moore Date: Wed, 6 Dec 2017 19:52:41 +0000 Subject: [PATCH 015/101] Allow setting a timeout value when intializing the clients to override the default of 30 seconds --- gdax/authenticated_client.py | 61 ++++++++++++++++++------------------ gdax/public_client.py | 5 +-- 2 files changed, 34 insertions(+), 32 deletions(-) diff --git a/gdax/authenticated_client.py b/gdax/authenticated_client.py index 5128a0ad..c9e3dd52 100644 --- a/gdax/authenticated_client.py +++ b/gdax/authenticated_client.py @@ -16,12 +16,13 @@ class AuthenticatedClient(PublicClient): - def __init__(self, key, b64secret, passphrase, api_url="https://api.gdax.com"): + 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=30) + r = requests.get(self.url + '/accounts/' + account_id, auth=self.auth, timeout=self.timeout) # r.raise_for_status() return r.json() @@ -30,7 +31,7 @@ def get_accounts(self): def get_account_history(self, account_id): result = [] - r = requests.get(self.url + '/accounts/{}/ledger'.format(account_id), auth=self.auth, timeout=30) + 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: @@ -38,7 +39,7 @@ def get_account_history(self, account_id): 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=30) + 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()) @@ -48,7 +49,7 @@ def history_pagination(self, account_id, result, after): def get_account_holds(self, account_id): result = [] - r = requests.get(self.url + '/accounts/{}/holds'.format(account_id), auth=self.auth, timeout=30) + 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: @@ -56,7 +57,7 @@ def get_account_holds(self, account_id): 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=30) + 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()) @@ -71,7 +72,7 @@ def buy(self, **kwargs): r = requests.post(self.url + '/orders', data=json.dumps(kwargs), auth=self.auth, - timeout=30) + timeout=self.timeout) return r.json() def sell(self, **kwargs): @@ -79,11 +80,11 @@ def sell(self, **kwargs): r = requests.post(self.url + '/orders', data=json.dumps(kwargs), auth=self.auth, - timeout=30) + timeout=self.timeout) return r.json() def cancel_order(self, order_id): - r = requests.delete(self.url + '/orders/' + order_id, auth=self.auth, timeout=30) + r = requests.delete(self.url + '/orders/' + order_id, auth=self.auth, timeout=self.timeout) # r.raise_for_status() return r.json() @@ -91,12 +92,12 @@ def cancel_all(self, product_id=''): url = self.url + '/orders/' if product_id: url += "?product_id={}&".format(str(product_id)) - r = requests.delete(url, auth=self.auth, timeout=30) + r = requests.delete(url, auth=self.auth, 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=30) + r = requests.get(self.url + '/orders/' + order_id, auth=self.auth, timeout=self.timeout) # r.raise_for_status() return r.json() @@ -108,7 +109,7 @@ def get_orders(self, product_id='', status=[]): params["product_id"] = product_id if status: params["status"] = status - r = requests.get(url, auth=self.auth, params=params, timeout=30) + 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: @@ -125,7 +126,7 @@ def paginate_orders(self, product_id, status, result, after): params["product_id"] = product_id if status: params["status"] = status - r = requests.get(url, auth=self.auth, params=params, timeout=30) + r = requests.get(url, auth=self.auth, params=params, timeout=self.timeout) # r.raise_for_status() if r.json(): result.append(r.json()) @@ -146,7 +147,7 @@ def get_fills(self, order_id='', product_id='', before='', after='', limit=''): url += "after={}&".format(str(after)) if limit: url += "limit={}&".format(str(limit)) - r = requests.get(url, auth=self.auth, timeout=30) + 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()): @@ -159,7 +160,7 @@ def paginate_fills(self, result, after, order_id='', product_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=30) + r = requests.get(url, auth=self.auth, timeout=self.timeout) # r.raise_for_status() if r.json(): result.append(r.json()) @@ -175,7 +176,7 @@ def get_fundings(self, result='', status='', after=''): url += "status={}&".format(str(status)) if after: url += 'after={}&'.format(str(after)) - r = requests.get(url, auth=self.auth, timeout=30) + r = requests.get(url, auth=self.auth, timeout=self.timeout) # r.raise_for_status() result.append(r.json()) if 'cb-after' in r.headers: @@ -187,7 +188,7 @@ def repay_funding(self, amount='', currency=''): "amount": amount, "currency": currency # example: USD } - r = requests.post(self.url + "/funding/repay", data=json.dumps(payload), auth=self.auth, timeout=30) + r = requests.post(self.url + "/funding/repay", data=json.dumps(payload), auth=self.auth, timeout=self.timeout) # r.raise_for_status() return r.json() @@ -198,12 +199,12 @@ def margin_transfer(self, margin_profile_id="", transfer_type="", currency="", a "currency": currency, # example: USD "amount": amount } - r = requests.post(self.url + "/profiles/margin-transfer", data=json.dumps(payload), auth=self.auth, timeout=30) + 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=30) + r = requests.get(self.url + "/position", auth=self.auth, timeout=self.timeout) # r.raise_for_status() return r.json() @@ -211,7 +212,7 @@ 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=30) + r = requests.post(self.url + "/position/close", data=json.dumps(payload), auth=self.auth, timeout=self.timeout) # r.raise_for_status() return r.json() @@ -221,7 +222,7 @@ def deposit(self, amount="", currency="", payment_method_id=""): "currency": currency, "payment_method_id": payment_method_id } - r = requests.post(self.url + "/deposits/payment-method", data=json.dumps(payload), auth=self.auth, timeout=30) + 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() @@ -231,7 +232,7 @@ def coinbase_deposit(self, amount="", currency="", coinbase_account_id=""): "currency": currency, "coinbase_account_id": coinbase_account_id } - r = requests.post(self.url + "/deposits/coinbase-account", data=json.dumps(payload), auth=self.auth, timeout=30) + 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() @@ -241,7 +242,7 @@ def withdraw(self, amount="", currency="", payment_method_id=""): "currency": currency, "payment_method_id": payment_method_id } - r = requests.post(self.url + "/withdrawals/payment-method", data=json.dumps(payload), auth=self.auth, timeout=30) + 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() @@ -251,7 +252,7 @@ def coinbase_withdraw(self, amount="", currency="", coinbase_account_id=""): "currency": currency, "coinbase_account_id": coinbase_account_id } - r = requests.post(self.url + "/withdrawals/coinbase", data=json.dumps(payload), auth=self.auth, timeout=30) + r = requests.post(self.url + "/withdrawals/coinbase", data=json.dumps(payload), auth=self.auth, timeout=self.timeout) # r.raise_for_status() return r.json() @@ -261,17 +262,17 @@ def crypto_withdraw(self, amount="", currency="", crypto_address=""): "currency": currency, "crypto_address": crypto_address } - r = requests.post(self.url + "/withdrawals/crypto", data=json.dumps(payload), auth=self.auth, timeout=30) + 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=30) + 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=30) + r = requests.get(self.url + "/coinbase-accounts", auth=self.auth, timeout=self.timeout) # r.raise_for_status() return r.json() @@ -286,16 +287,16 @@ def create_report(self, report_type="", start_date="", end_date="", product_id=" "format": report_format, "email": email } - r = requests.post(self.url + "/reports", data=json.dumps(payload), auth=self.auth, timeout=30) + 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=30) + 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=30) + r = requests.get(self.url + "/users/self/trailing-volume", auth=self.auth, timeout=self.timeout) # r.raise_for_status() return r.json() diff --git a/gdax/public_client.py b/gdax/public_client.py index 305f67b0..79fef798 100644 --- a/gdax/public_client.py +++ b/gdax/public_client.py @@ -18,7 +18,7 @@ class PublicClient(object): """ - def __init__(self, api_url='https://api.gdax.com'): + def __init__(self, api_url='https://api.gdax.com', timeout=30): """Create GDAX API public client. Args: @@ -26,11 +26,12 @@ def __init__(self, api_url='https://api.gdax.com'): """ 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=30) + r = requests.get(self.url + path, params=params, timeout=self.timeout) # r.raise_for_status() return r.json() From 07af33509c1dc6f9b3c8af649d38c3229a778f50 Mon Sep 17 00:00:00 2001 From: ankit1200 Date: Fri, 8 Dec 2017 22:33:31 -0500 Subject: [PATCH 016/101] self.ws.send doesn't need to happen twice --- gdax/websocket_client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/gdax/websocket_client.py b/gdax/websocket_client.py index 0c679e10..635c3ffd 100644 --- a/gdax/websocket_client.py +++ b/gdax/websocket_client.py @@ -66,7 +66,6 @@ def _connect(self): sub_params.update(get_auth_headers(timestamp, message, self.api_key, self.api_secret, self.api_passphrase)) self.ws = create_connection(self.url) - self.ws.send(json.dumps(sub_params)) if self.type == "heartbeat": sub_params = {"type": "heartbeat", "on": True} From f1bdf462b30fc2b080fe95c71b9ee6355f75b544 Mon Sep 17 00:00:00 2001 From: Skylar Saveland Date: Wed, 13 Dec 2017 12:17:19 -0800 Subject: [PATCH 017/101] #180 update authenticated_client --- gdax/authenticated_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gdax/authenticated_client.py b/gdax/authenticated_client.py index 5128a0ad..f01ca8a2 100644 --- a/gdax/authenticated_client.py +++ b/gdax/authenticated_client.py @@ -130,7 +130,7 @@ def paginate_orders(self, product_id, status, result, after): if r.json(): result.append(r.json()) if 'cb-after' in r.headers: - self.paginate_orders(product_id, result, r.headers['cb-after']) + self.paginate_orders(product_id, status, result, r.headers['cb-after']) return result def get_fills(self, order_id='', product_id='', before='', after='', limit=''): From 14a88c3a771fa96c8339d95db1dcb2b8d9806cf0 Mon Sep 17 00:00:00 2001 From: Myxomatosis Date: Sun, 17 Dec 2017 13:50:29 -0800 Subject: [PATCH 018/101] Fixed websocket authentication Changed lines 64-73 to get websocket authentication working. --- gdax/websocket_client.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/gdax/websocket_client.py b/gdax/websocket_client.py index de468e7e..96382cd4 100644 --- a/gdax/websocket_client.py +++ b/gdax/websocket_client.py @@ -62,8 +62,15 @@ def _connect(self): if self.auth: timestamp = str(time.time()) - message = timestamp + 'GET' + '/users/self' - sub_params.update(get_auth_headers(timestamp, message, self.api_key, self.api_secret, self.api_passphrase)) + 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 = signature.digest().encode('base64').rstrip('\n') + sub_params['signature'] = signature_b64 + sub_params['key'] = self.api_key + sub_params['passphrase'] = self.api_passphrase + sub_params['timestamp'] = timestamp self.ws = create_connection(self.url) self.ws.send(json.dumps(sub_params)) From 9970aee5edde9bbfe3388fc3be947dc73c5ffb11 Mon Sep 17 00:00:00 2001 From: alistair Date: Sun, 24 Dec 2017 17:26:58 +0000 Subject: [PATCH 019/101] Update as per documentation - https://docs.gdax.com/?python#coinbase54, issue #204 --- gdax/authenticated_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gdax/authenticated_client.py b/gdax/authenticated_client.py index bd1fe948..0a4f5d84 100644 --- a/gdax/authenticated_client.py +++ b/gdax/authenticated_client.py @@ -252,7 +252,7 @@ def coinbase_withdraw(self, amount="", currency="", coinbase_account_id=""): "currency": currency, "coinbase_account_id": coinbase_account_id } - r = requests.post(self.url + "/withdrawals/coinbase", data=json.dumps(payload), auth=self.auth, timeout=self.timeout) + 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() From 9f7e5020fc0d85ed667ba91f0a773a6104068f63 Mon Sep 17 00:00:00 2001 From: alistair Date: Sun, 24 Dec 2017 17:27:37 +0000 Subject: [PATCH 020/101] Make pep8 compliant --- gdax/gdax_auth.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/gdax/gdax_auth.py b/gdax/gdax_auth.py index 14757c3c..c4217331 100644 --- a/gdax/gdax_auth.py +++ b/gdax/gdax_auth.py @@ -14,8 +14,11 @@ def __init__(self, api_key, secret_key, passphrase): def __call__(self, request): timestamp = str(time.time()) - message = timestamp + request.method + request.path_url + (request.body or '') - request.headers.update(get_auth_headers(timestamp, message, self.api_key, self.secret_key, + message = ''.join([timestamp, request.method, + request.path_url, (request.body or '')]) + request.headers.update(get_auth_headers(timestamp, message, + self.api_key, + self.secret_key, self.passphrase)) return request From ff07566a0d381d8ee043e597f3adaf432eaae741 Mon Sep 17 00:00:00 2001 From: alistair Date: Mon, 25 Dec 2017 02:38:52 +0000 Subject: [PATCH 021/101] Fix unit test as per issue #203 --- tests/test_public_client.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_public_client.py b/tests/test_public_client.py index d33a1844..9f1ad785 100644 --- a/tests/test_public_client.py +++ b/tests/test_public_client.py @@ -1,6 +1,8 @@ import pytest import gdax import time +import datetime +from dateutil.relativedelta import relativedelta @pytest.fixture(scope='module') @@ -46,9 +48,11 @@ def test_get_product_trades(self, client): assert type(r) is list assert 'trade_id' in r[0] - @pytest.mark.parametrize('start', ('2017-11-01', None)) - @pytest.mark.parametrize('end', ('2017-11-30', None)) - @pytest.mark.parametrize('granularity', (3600, None)) + current_time = datetime.datetime.now() + + @pytest.mark.parametrize('start,end,granularity', + [(current_time - relativedelta(months=1), + current_time, 10000)]) 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 From ed6a3e00c3de8baef7df5a18b4a483a64c4f280e Mon Sep 17 00:00:00 2001 From: jlas Date: Sat, 30 Dec 2017 18:21:18 +0000 Subject: [PATCH 022/101] Send ping based on time elapsed, modulus op needs to line up exactly --- gdax/websocket_client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gdax/websocket_client.py b/gdax/websocket_client.py index 96382cd4..5f5ec020 100644 --- a/gdax/websocket_client.py +++ b/gdax/websocket_client.py @@ -84,9 +84,11 @@ def _connect(self): def _listen(self): while not self.stop: try: - if int(time.time() % 30) == 0: + 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: From 5e0bc6be2cd312879d51879f052de15917ad4c44 Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Mon, 1 Jan 2018 15:42:15 -0600 Subject: [PATCH 023/101] Generate GDAX Deposit Address --- gdax/authenticated_client.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/gdax/authenticated_client.py b/gdax/authenticated_client.py index bd1fe948..6fa125cd 100644 --- a/gdax/authenticated_client.py +++ b/gdax/authenticated_client.py @@ -300,3 +300,8 @@ 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() From 05ea2886dcd4f1078a67cb1275dd97351d9ee2b4 Mon Sep 17 00:00:00 2001 From: Sebastian Quilter Date: Wed, 3 Jan 2018 11:27:45 -0700 Subject: [PATCH 024/101] update websocket_client to support python3 Fixes #218 --- gdax/websocket_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gdax/websocket_client.py b/gdax/websocket_client.py index 96382cd4..747f23d7 100644 --- a/gdax/websocket_client.py +++ b/gdax/websocket_client.py @@ -66,7 +66,7 @@ def _connect(self): message = message.encode('ascii') hmac_key = base64.b64decode(self.api_secret) signature = hmac.new(hmac_key, message, hashlib.sha256) - signature_b64 = signature.digest().encode('base64').rstrip('\n') + 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 From dd236f8f421ebcce86eea70699c64cc04b2811b2 Mon Sep 17 00:00:00 2001 From: Dave Date: Sun, 7 Jan 2018 20:59:03 -0500 Subject: [PATCH 025/101] Catches unaccepted granularity levels in PublicClient.get_product_historic_rates(), and uses the nearest accepted level. --- gdax/public_client.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/gdax/public_client.py b/gdax/public_client.py index 79fef798..43e769d2 100644 --- a/gdax/public_client.py +++ b/gdax/public_client.py @@ -187,6 +187,11 @@ def get_product_historic_rates(self, product_id, start=None, end=None, if end is not None: params['end'] = end 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 return self._get('/products/{}/candles'.format(str(product_id)), params=params) From 127cc3c09e6adb0204da8d4cdc743f42b80bdce2 Mon Sep 17 00:00:00 2001 From: Chase Shimmin Date: Thu, 18 Jan 2018 08:33:32 -0500 Subject: [PATCH 026/101] Fix bug in AuthenticatedClient.cancel_all() Fixed bug where `cancel_all()` deletes all orders, even if `product_id` argument is specified. --- gdax/authenticated_client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/gdax/authenticated_client.py b/gdax/authenticated_client.py index bd1fe948..b71a241d 100644 --- a/gdax/authenticated_client.py +++ b/gdax/authenticated_client.py @@ -90,9 +90,10 @@ def cancel_order(self, order_id): def cancel_all(self, product_id=''): url = self.url + '/orders/' + params = {} if product_id: - url += "?product_id={}&".format(str(product_id)) - r = requests.delete(url, auth=self.auth, timeout=self.timeout) + params["product_id"] = product_id + r = requests.delete(url, auth=self.auth, params=params, timeout=self.timeout) # r.raise_for_status() return r.json() From cf98e87657f703064eca0ecca282f54c178fcdeb Mon Sep 17 00:00:00 2001 From: Todd Sharpe Date: Sat, 20 Jan 2018 01:03:24 -0500 Subject: [PATCH 027/101] Adding .cache/ and .coverage to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index cb98d5cc..60adeb5b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ dist/ venv/ *log.txt gdax/__pycache__/ +.cache/ +.coverage +tests/__pycache__/ From 47b216c0cb13345b99a82a652f1f17a7424a17e4 Mon Sep 17 00:00:00 2001 From: TheFrostyboss Date: Sat, 20 Jan 2018 12:41:25 -0700 Subject: [PATCH 028/101] README.md Fixed cancel_all documentation (product => product_id) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 64ea76e6..d199167d 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ auth_client.cancel_order("d50ec984-77a8-460a-b958-66f114b0de9b") ``` - [cancel_all](https://docs.gdax.com/#cancel-all) ```python -auth_client.cancel_all(product='BTC-USD') +auth_client.cancel_all(product_id='BTC-USD') ``` - [get_orders](https://docs.gdax.com/#list-orders) (paginated) From a7ca57021ab9024f644e1be2e8786abe7f1b80b4 Mon Sep 17 00:00:00 2001 From: Cabi Date: Sun, 11 Feb 2018 16:37:03 +0000 Subject: [PATCH 029/101] Add Dockerfile --- Dockerfile | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..10de529b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# Docker environment for ubuntu, conda, python3.6 +# +# Usage: +# * build the image: +# gdax-python$ docker build -t gdax-python . +# * start the image: +# docker run -it gdax-python + +# Latest version of ubuntu +FROM ubuntu:16.04 + +# Install system packages +RUN apt-get update && \ + apt-get install -y wget git libhdf5-dev g++ graphviz openmpi-bin libgl1-mesa-glx bzip2 + +# Install conda +ENV CONDA_DIR /opt/conda +ENV PATH $CONDA_DIR/bin:$PATH + +RUN wget --quiet https://repo.continuum.io/miniconda/Miniconda3-4.2.12-Linux-x86_64.sh && \ + echo "c59b3dd3cad550ac7596e0d599b91e75d88826db132e4146030ef471bb434e9a *Miniconda3-4.2.12-Linux-x86_64.sh" | sha256sum -c - && \ + /bin/bash /Miniconda3-4.2.12-Linux-x86_64.sh -f -b -p $CONDA_DIR && \ + rm Miniconda3-4.2.12-Linux-x86_64.sh && \ + echo export PATH=$CONDA_DIR/bin:'$PATH' > /etc/profile.d/conda.sh + +# Install Python packages +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 + +RUN mkdir -p $CODE_DIR +COPY . $CODE_DIR + +RUN cd $CODE_DIR && \ + pip install gdax From aa0b9d7cdda4b67936da7689ed5df7589c92f22f Mon Sep 17 00:00:00 2001 From: Gabriel Smadi Date: Sat, 17 Feb 2018 22:23:04 -0500 Subject: [PATCH 030/101] Bump version pymongo==3.6.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2ef54de0..4a4d5dd8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,4 @@ bintrees==2.0.7 requests==2.13.0 six==1.10.0 websocket-client==0.40.0 -pymongo==3.5.1 \ No newline at end of file +pymongo==3.6.0 \ No newline at end of file From 17f2d512acd48cf2ab4c4c185af83a99ed02be17 Mon Sep 17 00:00:00 2001 From: Gabriel Smadi Date: Thu, 22 Feb 2018 22:00:32 -0500 Subject: [PATCH 031/101] Fix heartbeat subscribe failure of first message --- gdax/order_book.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gdax/order_book.py b/gdax/order_book.py index 6af28f0c..b707c911 100644 --- a/gdax/order_book.py +++ b/gdax/order_book.py @@ -60,7 +60,7 @@ def on_message(self, message): if self._log_to: pickle.dump(message, self._log_to) - sequence = message['sequence'] + sequence = message.get('sequence', -1) if self._sequence == -1: self.reset_book() return From 5d19e372daa621ec8e29f64569634b64ee4f72d4 Mon Sep 17 00:00:00 2001 From: Gabriel Smadi Date: Thu, 22 Feb 2018 22:07:54 -0500 Subject: [PATCH 032/101] Revert pymongo version for unneeded upgrade --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4a4d5dd8..2ef54de0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,4 @@ bintrees==2.0.7 requests==2.13.0 six==1.10.0 websocket-client==0.40.0 -pymongo==3.6.0 \ No newline at end of file +pymongo==3.5.1 \ No newline at end of file From 267bdd102b570b22d1627d94070540bf17451bda Mon Sep 17 00:00:00 2001 From: Nicolas Steven Miller Date: Sun, 25 Feb 2018 14:46:45 -0800 Subject: [PATCH 033/101] fix websocket client heartbeat startup failure --- gdax/websocket_client.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/gdax/websocket_client.py b/gdax/websocket_client.py index 762f6b1c..47000b12 100644 --- a/gdax/websocket_client.py +++ b/gdax/websocket_client.py @@ -74,10 +74,6 @@ def _connect(self): self.ws = create_connection(self.url) - if self.type == "heartbeat": - sub_params = {"type": "heartbeat", "on": True} - else: - sub_params = {"type": "heartbeat", "on": False} self.ws.send(json.dumps(sub_params)) def _listen(self): @@ -98,8 +94,6 @@ def _listen(self): self.on_message(msg) def _disconnect(self): - if self.type == "heartbeat": - self.ws.send(json.dumps({"type": "heartbeat", "on": False})) try: if self.ws: self.ws.close() From f1a505ddc56be6c341b1de355b7aaa324d614fd7 Mon Sep 17 00:00:00 2001 From: Brian Boonstra Date: Sun, 11 Mar 2018 14:10:09 -0500 Subject: [PATCH 034/101] Test for level 3 fixes bug with 2 tests for level 2 --- tests/test_public_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_public_client.py b/tests/test_public_client.py index 9f1ad785..4e9fd441 100644 --- a/tests/test_public_client.py +++ b/tests/test_public_client.py @@ -34,7 +34,7 @@ def test_get_product_order_book(self, client, level): if level is 2 and (len(r['asks']) > 50 or len(r['bids']) > 50): pytest.fail('Fail: Level 2 should only return the top 50 asks and bids') - if level is 2 and (len(r['asks']) < 50 or len(r['bids']) < 50): + if level is 3 and (len(r['asks']) < 50 or len(r['bids']) < 50): pytest.fail('Fail: Level 3 should return the full order book') def test_get_product_ticker(self, client): From 1ad117dcd22f05667300810905301d3deb3ea5e1 Mon Sep 17 00:00:00 2001 From: Brian Boonstra Date: Sun, 11 Mar 2018 14:12:05 -0500 Subject: [PATCH 035/101] Replace bintree.RBTree (deprecated by its author) with faster sortedcontainers.SortedDict (recommended by bintree authors) in order book object. Performance testing indicates a few percent faster now. --- gdax/order_book.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/gdax/order_book.py b/gdax/order_book.py index 6af28f0c..3c4fc2eb 100644 --- a/gdax/order_book.py +++ b/gdax/order_book.py @@ -4,7 +4,7 @@ # # Live order book updated from the gdax Websocket Feed -from bintrees import RBTree +from sortedcontainers import SortedDict from decimal import Decimal import pickle @@ -15,8 +15,8 @@ class OrderBook(WebsocketClient): def __init__(self, product_id='BTC-USD', log_to=None): super(OrderBook, self).__init__(products=product_id) - self._asks = RBTree() - self._bids = RBTree() + self._asks = SortedDict() + self._bids = SortedDict() self._client = PublicClient() self._sequence = -1 self._log_to = log_to @@ -37,8 +37,8 @@ def on_close(self): print("\n-- OrderBook Socket Closed! --") def reset_book(self): - self._asks = RBTree() - self._bids = RBTree() + self._asks = SortedDict() + self._bids = SortedDict() res = self._client.get_product_order_book(product_id=self.product_id, level=3) for bid in res['bids']: self.add({ @@ -219,28 +219,28 @@ def get_current_book(self): return result def get_ask(self): - return self._asks.min_key() + return self._asks.peekitem(0) def get_asks(self, price): return self._asks.get(price) def remove_asks(self, price): - self._asks.remove(price) + del self._asks[price] def set_asks(self, price, asks): - self._asks.insert(price, asks) + self._asks[price] = asks def get_bid(self): - return self._bids.max_key() + return self._bids.peekitem(-1) def get_bids(self, price): return self._bids.get(price) def remove_bids(self, price): - self._bids.remove(price) + del self._bids[price] def set_bids(self, price, bids): - self._bids.insert(price, bids) + self._bids[price] = bids if __name__ == '__main__': From 68f0e9018ab85298820b3c6e4671bf7deaa52988 Mon Sep 17 00:00:00 2001 From: Brian Boonstra Date: Sun, 11 Mar 2018 14:15:08 -0500 Subject: [PATCH 036/101] Replace bintree.RBTree (deprecated by its author) with faster sortedcontainers.SortedDict (recommended by bintree authors) in order book object. --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f7f58718..8a53a75c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -bintrees==2.0.7 +sortedcontainers>=1.5.9 requests==2.13.0 six==1.10.0 websocket-client==0.40.0 From 308d59a6f10c3265eab79e9227a4b2a348777720 Mon Sep 17 00:00:00 2001 From: Brian Boonstra Date: Tue, 13 Mar 2018 20:41:35 -0500 Subject: [PATCH 037/101] Restore top of book to previous single price behavior --- gdax/order_book.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gdax/order_book.py b/gdax/order_book.py index 3c4fc2eb..80d4a610 100644 --- a/gdax/order_book.py +++ b/gdax/order_book.py @@ -219,7 +219,7 @@ def get_current_book(self): return result def get_ask(self): - return self._asks.peekitem(0) + return self._asks.peekitem(0)[0] def get_asks(self, price): return self._asks.get(price) @@ -231,7 +231,7 @@ def set_asks(self, price, asks): self._asks[price] = asks def get_bid(self): - return self._bids.peekitem(-1) + return self._bids.peekitem(-1)[0] def get_bids(self, price): return self._bids.get(price) From da5c38bc14edd8973bb364361c07631e382512fe Mon Sep 17 00:00:00 2001 From: Tamer Saadeh Date: Fri, 23 Mar 2018 17:40:30 +0100 Subject: [PATCH 038/101] Allow get_product_trades to be paginated This implements the pagination of trades, as per the docs (https://docs.gdax.com/#get-trades). --- gdax/public_client.py | 47 ++++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/gdax/public_client.py b/gdax/public_client.py index 43e769d2..3ce4fd29 100644 --- a/gdax/public_client.py +++ b/gdax/public_client.py @@ -119,30 +119,35 @@ def get_product_ticker(self, product_id): """ return self._get('/products/{}/ticker'.format(str(product_id))) - def get_product_trades(self, product_id): - """List the latest trades for a product. + def get_product_trades(self, product_id, before='', after='', limit='', result=[]): + url = self.url + '/products/{}/trades'.format(str(product_id)) + params = {} - Args: - product_id (str): Product + 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 - Returns: - list: Latest trades. Example:: - [{ - "time": "2014-11-07T22:19:28.578544Z", - "trade_id": 74, - "price": "10.00000000", - "size": "0.01000000", - "side": "buy" - }, { - "time": "2014-11-07T01:08:43.642366Z", - "trade_id": 73, - "price": "100.00000000", - "size": "0.01000000", - "side": "sell" - }] + r = requests.get(url, params=params) + r.raise_for_status() - """ - return self._get('/products/{}/trades'.format(str(product_id))) + 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 + + # 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 def get_product_historic_rates(self, product_id, start=None, end=None, granularity=None): From 23f8949b24773405ce9fffdbe123f7365ee6695b Mon Sep 17 00:00:00 2001 From: Tamer Saadeh Date: Fri, 23 Mar 2018 17:51:04 +0100 Subject: [PATCH 039/101] add docs --- gdax/public_client.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/gdax/public_client.py b/gdax/public_client.py index 3ce4fd29..0c970e5e 100644 --- a/gdax/public_client.py +++ b/gdax/public_client.py @@ -120,6 +120,30 @@ def get_product_ticker(self, product_id): return self._get('/products/{}/ticker'.format(str(product_id))) def get_product_trades(self, product_id, before='', after='', limit='', result=[]): + """List the latest trades for a product. + Args: + product_id (str): Product + before (Optional[str]): start time in ISO 8601 + after (Optional[str]): end time in ISO 8601 + limit (Optional[int]): the desired number of trades (can be more than 100, + automatically paginated) + results (Optional[list]): list of results that is used for the pagination + Returns: + list: Latest trades. Example:: + [{ + "time": "2014-11-07T22:19:28.578544Z", + "trade_id": 74, + "price": "10.00000000", + "size": "0.01000000", + "side": "buy" + }, { + "time": "2014-11-07T01:08:43.642366Z", + "trade_id": 73, + "price": "100.00000000", + "size": "0.01000000", + "side": "sell" + }] + """" url = self.url + '/products/{}/trades'.format(str(product_id)) params = {} From fb2f0d6d0775e341f39ce161efbb920c0684514a Mon Sep 17 00:00:00 2001 From: Tamer Saadeh Date: Sat, 24 Mar 2018 11:41:33 +0100 Subject: [PATCH 040/101] remove raise and comment out time.sleep --- gdax/public_client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gdax/public_client.py b/gdax/public_client.py index 0c970e5e..282021a4 100644 --- a/gdax/public_client.py +++ b/gdax/public_client.py @@ -157,7 +157,7 @@ def get_product_trades(self, product_id, before='', after='', limit='', result=[ params['limit'] = limit r = requests.get(url, params=params) - r.raise_for_status() + # r.raise_for_status() result.extend(r.json()) @@ -167,8 +167,8 @@ def get_product_trades(self, product_id, before='', after='', limit='', result=[ if limit <= 0: return result - # ensure that we don't get rate-limited/blocked - time.sleep(0.4) + # 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 From 8f2539bfa50f9c10c095e77ed5cd582402801075 Mon Sep 17 00:00:00 2001 From: Nikalexis Nikos Date: Tue, 3 Apr 2018 19:56:02 +0300 Subject: [PATCH 041/101] Fix SyntaxError: EOL while scanning string literal --- gdax/public_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gdax/public_client.py b/gdax/public_client.py index 282021a4..a998873a 100644 --- a/gdax/public_client.py +++ b/gdax/public_client.py @@ -143,7 +143,7 @@ def get_product_trades(self, product_id, before='', after='', limit='', result=[ "size": "0.01000000", "side": "sell" }] - """" + """ url = self.url + '/products/{}/trades'.format(str(product_id)) params = {} From 49613dec472d55a6a3e8bdd845de5d02ec2cb96d Mon Sep 17 00:00:00 2001 From: Aaron Adler Date: Mon, 18 Jun 2018 23:44:22 -0700 Subject: [PATCH 042/101] Add requirement required to run unit tests --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8a53a75c..923b4555 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ 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 +pytest-cov>=2.5.0 +py-dateutil==2.2 From 6ba95d921dbc4e805384e5fd74bc4800548bc83a Mon Sep 17 00:00:00 2001 From: Aaron Adler Date: Mon, 18 Jun 2018 23:50:22 -0700 Subject: [PATCH 043/101] Replace string default for int argument with None Unit test for `get_product_trades` now passes --- gdax/public_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gdax/public_client.py b/gdax/public_client.py index a998873a..24dbb4ec 100644 --- a/gdax/public_client.py +++ b/gdax/public_client.py @@ -119,7 +119,7 @@ def get_product_ticker(self, product_id): """ return self._get('/products/{}/ticker'.format(str(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=[]): """List the latest trades for a product. Args: product_id (str): Product @@ -161,7 +161,7 @@ def get_product_trades(self, product_id, before='', after='', limit='', result=[ result.extend(r.json()) - if 'cb-after' in r.headers and limit is not len(result): + if 'cb-after' in r.headers and limit is not len(result) and limit is not None: # update limit limit -= len(result) if limit <= 0: From 5ea926ec69587ee77cfb541b70a26c6e98484758 Mon Sep 17 00:00:00 2001 From: Aaron Adler Date: Mon, 18 Jun 2018 23:50:32 -0700 Subject: [PATCH 044/101] Ignore test outputs --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 60adeb5b..7dcfef5a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ gdax/__pycache__/ .cache/ .coverage tests/__pycache__/ +.pytest_cache From a51463f4915cf3743ec12e57e489f39999ecc83f Mon Sep 17 00:00:00 2001 From: Aaron Adler Date: Mon, 18 Jun 2018 23:53:46 -0700 Subject: [PATCH 045/101] Replace named parameter with mutable argument In Python, named parameters that are set to default to mutable arguments retain changes to that argument across function calls http://effbot.org/zone/default-values.htm --- gdax/public_client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gdax/public_client.py b/gdax/public_client.py index 24dbb4ec..0a4e4182 100644 --- a/gdax/public_client.py +++ b/gdax/public_client.py @@ -119,7 +119,7 @@ def get_product_ticker(self, product_id): """ return self._get('/products/{}/ticker'.format(str(product_id))) - def get_product_trades(self, product_id, before='', after='', limit=None, result=[]): + def get_product_trades(self, product_id, before='', after='', limit=None, result=None): """List the latest trades for a product. Args: product_id (str): Product @@ -144,6 +144,9 @@ def get_product_trades(self, product_id, before='', after='', limit=None, result "side": "sell" }] """ + if result is None: + result = [] + url = self.url + '/products/{}/trades'.format(str(product_id)) params = {} From 90e5c62887162013da8c160d6e6775c2b4dc225e Mon Sep 17 00:00:00 2001 From: Aaron Adler Date: Tue, 19 Jun 2018 00:20:11 -0700 Subject: [PATCH 046/101] Switch granularity to only use approved values or throw ValueError --- gdax/public_client.py | 10 +++++----- tests/test_public_client.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/gdax/public_client.py b/gdax/public_client.py index 0a4e4182..656b2ff8 100644 --- a/gdax/public_client.py +++ b/gdax/public_client.py @@ -202,10 +202,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 ], @@ -221,9 +221,9 @@ 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 + raise ValueError( 'Specified granularity is {}, must be in approved values: {}'.format( + granularity, acceptedGrans) ) + params['granularity'] = granularity return self._get('/products/{}/candles'.format(str(product_id)), params=params) diff --git a/tests/test_public_client.py b/tests/test_public_client.py index 4e9fd441..c70a7f70 100644 --- a/tests/test_public_client.py +++ b/tests/test_public_client.py @@ -52,7 +52,7 @@ 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 From 58a75c82c47ab702b13faf316a7f9cd901c33ccd Mon Sep 17 00:00:00 2001 From: Aaron Adler Date: Tue, 19 Jun 2018 00:35:47 -0700 Subject: [PATCH 047/101] Add better unit test case --- tests/test_public_client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_public_client.py b/tests/test_public_client.py index c70a7f70..8064591e 100644 --- a/tests/test_public_client.py +++ b/tests/test_public_client.py @@ -56,6 +56,8 @@ def test_get_product_trades(self, client): 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') From 8a82b8010a0f07ff7adb74d70b9ad265f1ab17de Mon Sep 17 00:00:00 2001 From: maxl11 Date: Fri, 6 Jul 2018 15:00:44 +0200 Subject: [PATCH 048/101] update of api-url --- gdax/public_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gdax/public_client.py b/gdax/public_client.py index a998873a..5641b65d 100644 --- a/gdax/public_client.py +++ b/gdax/public_client.py @@ -18,7 +18,7 @@ class PublicClient(object): """ - def __init__(self, api_url='https://api.gdax.com', timeout=30): + def __init__(self, api_url='https://api.pro.coinbase.com', timeout=30): """Create GDAX API public client. Args: From 4a3aadbf5d0e8df8418c581a6fa23dadb849842e Mon Sep 17 00:00:00 2001 From: Tim Paine Date: Fri, 13 Jul 2018 09:56:20 -0400 Subject: [PATCH 049/101] outdated dependency in setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 368ee131..15a83d03 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import setup, find_packages install_requires = [ - 'bintrees==2.0.7', + 'sortedcontainers>=1.5.9', 'requests==2.13.0', 'six==1.10.0', 'websocket-client==0.40.0', From 54b7b05db759d98d8fb6f929644b2d489befd420 Mon Sep 17 00:00:00 2001 From: Daniel Paquin Date: Mon, 13 Aug 2018 09:44:52 -0400 Subject: [PATCH 050/101] Update README.md --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d199167d..34af692f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# gdax-python -The Python client for the [GDAX API](https://docs.gdax.com/) (formerly known as -the Coinbase Exchange API) +# 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 From ced08c654b1638af1d879d1ec06c5d3505b920d4 Mon Sep 17 00:00:00 2001 From: Daniel Paquin Date: Sun, 19 Aug 2018 19:08:30 -0400 Subject: [PATCH 051/101] Release of cbpro 1.1.0 --- Dockerfile | 10 +- README.md | 103 ++-- cbpro/.gitignore | 2 + cbpro/__init__.py | 5 + cbpro/authenticated_client.py | 995 ++++++++++++++++++++++++++++++++++ cbpro/cbpro_auth.py | 37 ++ cbpro/order_book.py | 298 ++++++++++ cbpro/public_client.py | 310 +++++++++++ cbpro/websocket_client.py | 160 ++++++ pytest.ini | 2 +- setup.py | 18 +- 11 files changed, 1879 insertions(+), 61 deletions(-) create mode 100644 cbpro/.gitignore create mode 100644 cbpro/__init__.py create mode 100644 cbpro/authenticated_client.py create mode 100644 cbpro/cbpro_auth.py create mode 100644 cbpro/order_book.py create mode 100644 cbpro/public_client.py create mode 100644 cbpro/websocket_client.py 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 441bbc7a..77c26e89 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,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 +29,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 +47,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,37 +65,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) (paginated) +- [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() ``` @@ -103,22 +105,22 @@ 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://www.pro.coinbase.com/settings/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. The GDAX Python API provides +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 @@ -133,7 +135,7 @@ 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 GDAX. +returned - this is a limitation of CB Pro. ```python from itertools import islice # Get 5 most recent fills @@ -143,29 +145,29 @@ 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 @@ -202,27 +204,27 @@ auth_client.place_stop_order(product_id='BTC-USD', 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() @@ -232,9 +234,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' @@ -242,7 +243,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' @@ -252,22 +253,22 @@ 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 +import cbpro # Paramters are optional -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") # Do other stuff... wsClient.close() ``` #### Subscribe to multiple products ```python -import gdax +import cbpro # Paramaters are optional -wsClient = gdax.WebsocketClient(url="wss://ws-feed.gdax.com", +wsClient = cbpro.WebsocketClient(url="wss://ws-feed.pro.coinbase.com", products=["BTC-USD", "ETH-USD"]) # Do other stuff... wsClient.close() @@ -280,7 +281,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 @@ -288,7 +289,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() ``` @@ -306,10 +307,10 @@ argument that contains the message of dict type. - 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!") @@ -344,8 +345,8 @@ the orderbook for the product_id input. Please provide your feedback for future improvements. ```python -import gdax, time -order_book = gdax.OrderBook(product_id='BTC-USD') +import cbpro, time +order_book = cbpro.OrderBook(product_id='BTC-USD') order_book.start() time.sleep(10) order_book.close() @@ -362,6 +363,10 @@ python -m pytest ``` ## Change Log +*1.1* +- Refactor project for Coinbase Pro +- Major overhaul on how pagination is handled + *1.0* **Current PyPI release** - The first release that is not backwards compatible - Refactored to follow PEP 8 Standards diff --git a/cbpro/.gitignore b/cbpro/.gitignore new file mode 100644 index 00000000..79c40e7a --- /dev/null +++ b/cbpro/.gitignore @@ -0,0 +1,2 @@ +*.pyc +FixClient.py \ No newline at end of file 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..f45f31f5 --- /dev/null +++ b/cbpro/authenticated_client.py @@ -0,0 +1,995 @@ +# +# 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 place_order(self, product_id, side, order_type, **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', 'market', or 'stop') + **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('tif') != '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('tif') 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 order_type == '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, side, 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') + side (str): Order side ('buy' or 'sell) + 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. + + """ + params = {'product_id': product_id, + 'side': side, + 'price': price, + 'order_type': 'stop', + '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} + data = json.dumps(params) + else: + data = None + return self._send_message('delete', '/orders', data=data) + + 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. + + 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. + + As of 8/23/18 - Requests without either order_id or product_id + will be rejected + + 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" + }, + { + ... + } + ] + + """ + 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', + 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') diff --git a/cbpro/cbpro_auth.py b/cbpro/cbpro_auth.py new file mode 100644 index 00000000..db6561fe --- /dev/null +++ b/cbpro/cbpro_auth.py @@ -0,0 +1,37 @@ +import hmac +import hashlib +import time +import base64 +from requests.auth import AuthBase + + +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 + self.passphrase = passphrase + + def __call__(self, request): + timestamp = str(time.time()) + message = ''.join([timestamp, request.method, + request.path_url, (request.body or '')]) + request.headers.update(get_auth_headers(timestamp, message, + self.api_key, + self.secret_key, + self.passphrase)) + return request + + +def get_auth_headers(timestamp, message, api_key, secret_key, passphrase): + message = message.encode('ascii') + hmac_key = base64.b64decode(secret_key) + signature = hmac.new(hmac_key, message, hashlib.sha256) + signature_b64 = base64.b64encode(signature.digest()).decode('utf-8') + return { + 'Content-Type': 'Application/JSON', + 'CB-ACCESS-SIGN': signature_b64, + 'CB-ACCESS-TIMESTAMP': timestamp, + 'CB-ACCESS-KEY': api_key, + 'CB-ACCESS-PASSPHRASE': passphrase + } diff --git a/cbpro/order_book.py b/cbpro/order_book.py new file mode 100644 index 00000000..d3ea56eb --- /dev/null +++ b/cbpro/order_book.py @@ -0,0 +1,298 @@ +# +# gdax/order_book.py +# David Caseria +# +# Live order book updated from the gdax Websocket Feed + +from sortedcontainers import SortedDict +from decimal import Decimal +import pickle + +from gdax.public_client import PublicClient +from gdax.websocket_client import WebsocketClient + + +class OrderBook(WebsocketClient): + 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() + self._sequence = -1 + self._log_to = log_to + 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! --") + + def reset_book(self): + self._asks = SortedDict() + self._bids = SortedDict() + res = self._client.get_product_order_book(product_id=self.product_id, level=3) + for bid in res['bids']: + self.add({ + 'id': bid[2], + 'side': 'buy', + 'price': Decimal(bid[0]), + 'size': Decimal(bid[1]) + }) + for ask in res['asks']: + self.add({ + 'id': ask[2], + 'side': 'sell', + 'price': Decimal(ask[0]), + 'size': Decimal(ask[1]) + }) + self._sequence = res['sequence'] + + def on_message(self, message): + if self._log_to: + pickle.dump(message, self._log_to) + + 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) + + 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 = { + 'id': order.get('order_id') or order['id'], + 'side': order['side'], + 'price': Decimal(order['price']), + 'size': Decimal(order.get('size') or order['remaining_size']) + } + if order['side'] == 'buy': + bids = self.get_bids(order['price']) + if bids is None: + bids = [order] + else: + bids.append(order) + self.set_bids(order['price'], bids) + else: + asks = self.get_asks(order['price']) + if asks is None: + asks = [order] + else: + asks.append(order) + self.set_asks(order['price'], asks) + + def remove(self, order): + price = Decimal(order['price']) + if order['side'] == 'buy': + bids = self.get_bids(price) + if bids is not None: + bids = [o for o in bids if o['id'] != order['order_id']] + if len(bids) > 0: + self.set_bids(price, bids) + else: + self.remove_bids(price) + else: + asks = self.get_asks(price) + if asks is not None: + asks = [o for o in asks if o['id'] != order['order_id']] + if len(asks) > 0: + self.set_asks(price, asks) + else: + self.remove_asks(price) + + def match(self, order): + size = Decimal(order['size']) + price = Decimal(order['price']) + + if order['side'] == 'buy': + bids = self.get_bids(price) + if not bids: + return + assert bids[0]['id'] == order['maker_order_id'] + if bids[0]['size'] == size: + self.set_bids(price, bids[1:]) + else: + bids[0]['size'] -= size + self.set_bids(price, bids) + else: + asks = self.get_asks(price) + if not asks: + return + assert asks[0]['id'] == order['maker_order_id'] + if asks[0]['size'] == size: + self.set_asks(price, asks[1:]) + else: + asks[0]['size'] -= size + self.set_asks(price, asks) + + def change(self, order): + try: + new_size = Decimal(order['new_size']) + except KeyError: + return + + try: + price = Decimal(order['price']) + except KeyError: + return + + if order['side'] == 'buy': + bids = self.get_bids(price) + if bids is None or not any(o['id'] == order['order_id'] for o in bids): + return + index = [b['id'] for b in bids].index(order['order_id']) + bids[index]['size'] = new_size + self.set_bids(price, bids) + else: + asks = self.get_asks(price) + if asks is None or not any(o['id'] == order['order_id'] for o in asks): + return + index = [a['id'] for a in asks].index(order['order_id']) + asks[index]['size'] = new_size + self.set_asks(price, asks) + + tree = self._asks if order['side'] == 'sell' else self._bids + node = tree.get(price) + + if node is None or not any(o['id'] == order['order_id'] for o in node): + return + + def get_current_ticker(self): + return self._current_ticker + + def get_current_book(self): + result = { + 'sequence': self._sequence, + 'asks': [], + 'bids': [], + } + for ask in self._asks: + try: + # There can be a race condition here, where a price point is removed + # between these two ops + this_ask = self._asks[ask] + except KeyError: + continue + for order in this_ask: + result['asks'].append([order['price'], order['size'], order['id']]) + for bid in self._bids: + try: + # There can be a race condition here, where a price point is removed + # between these two ops + this_bid = self._bids[bid] + except KeyError: + continue + + for order in this_bid: + result['bids'].append([order['price'], order['size'], order['id']]) + return result + + def get_ask(self): + return self._asks.peekitem(0)[0] + + def get_asks(self, price): + return self._asks.get(price) + + def remove_asks(self, price): + del self._asks[price] + + def set_asks(self, price, asks): + self._asks[price] = asks + + def get_bid(self): + return self._bids.peekitem(-1)[0] + + def get_bids(self, price): + return self._bids.get(price) + + def remove_bids(self, price): + del self._bids[price] + + def set_bids(self, price, bids): + self._bids[price] = bids + + +if __name__ == '__main__': + import sys + import time + import datetime as dt + + + class OrderBookConsole(OrderBook): + ''' Logs real-time changes to the bid-ask spread to the console ''' + + def __init__(self, product_id=None): + super(OrderBookConsole, self).__init__(product_id=product_id) + + # latest values of bid-ask spread + self._bid = None + self._ask = 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() + try: + while True: + time.sleep(10) + except KeyboardInterrupt: + order_book.close() + + if order_book.error: + sys.exit(1) + else: + sys.exit(0) diff --git a/cbpro/public_client.py b/cbpro/public_client.py new file mode 100644 index 00000000..52528964 --- /dev/null +++ b/cbpro/public_client.py @@ -0,0 +1,310 @@ +# +# cbpro/PublicClient.py +# Daniel Paquin +# +# For public requests to the Coinbase exchange + +import requests + + +class PublicClient(object): + """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 cbpro API. + + """ + + 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 cbpro API. + + """ + self.url = api_url.rstrip('/') + self.auth = None + self.session = requests.Session() + + def get_products(self): + """Get a list of available currency pairs for trading. + + Returns: + list: Info about all currency pairs. Example:: + [ + { + "id": "BTC-USD", + "display_name": "BTC/USD", + "base_currency": "BTC", + "quote_currency": "USD", + "base_min_size": "0.01", + "base_max_size": "10000.00", + "quote_increment": "0.01" + } + ] + + """ + 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. + + The amount of detail shown can be customized with the `level` + parameter: + * 1: Only the best bid and ask + * 2: Top 50 bids and asks (aggregated) + * 3: Full order book (non aggregated) + + Level 1 and Level 2 are recommended for polling. For the most + up-to-date data, consider using the websocket stream. + + **Caution**: Level 3 is only recommended for users wishing to + maintain a full real-time order book using the websocket + stream. Abuse of Level 3 via polling will cause your access to + be limited or blocked. + + Args: + product_id (str): Product + level (Optional[int]): Order book level (1, 2, or 3). + Default is 1. + + Returns: + dict: Order book. Example for level 1:: + { + "sequence": "3", + "bids": [ + [ price, size, num-orders ], + ], + "asks": [ + [ price, size, num-orders ], + ] + } + + """ + 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. + + **Caution**: Polling is discouraged in favor of connecting via + the websocket stream and listening for match messages. + + Args: + product_id (str): Product + + Returns: + dict: Ticker info. Example:: + { + "trade_id": 4729088, + "price": "333.99", + "size": "0.193", + "bid": "333.98", + "ask": "333.99", + "volume": "5957.11914015", + "time": "2015-11-14T20:46:03.511254Z" + } + + """ + return self._send_message('get', + '/products/{}/ticker'.format(product_id)) + + 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 + after (Optional[str]): end time in ISO 8601 + limit (Optional[int]): the desired number of trades (can be more than 100, + automatically paginated) + results (Optional[list]): list of results that is used for the pagination + Returns: + list: Latest trades. Example:: + [{ + "time": "2014-11-07T22:19:28.578544Z", + "trade_id": 74, + "price": "10.00000000", + "size": "0.01000000", + "side": "buy" + }, { + "time": "2014-11-07T01:08:43.642366Z", + "trade_id": 73, + "price": "100.00000000", + "size": "0.01000000", + "side": "sell" + }] + """ + return self._send_paginated_message('/products/{}/trades' + .format(product_id)) + + def get_product_historic_rates(self, product_id, start=None, end=None, + granularity=None): + """Historic rates for a product. + + Rates are returned in grouped buckets based on requested + `granularity`. If start, end, and granularity aren't provided, + the exchange will assume some (currently unknown) default values. + + Historical rate data may be incomplete. No data is published for + intervals where there are no ticks. + + **Caution**: Historical rates should not be polled frequently. + If you need real-time information, use the trade and book + endpoints along with the websocket feed. + + The maximum number of data points for a single request is 200 + candles. If your selection of start/end time and granularity + will result in more than 200 data points, your request will be + rejected. If you wish to retrieve fine granularity data over a + larger time range, you will need to make multiple requests with + new start/end ranges. + + Args: + product_id (str): Product + start (Optional[str]): Start time in ISO 8601 + end (Optional[str]): End time in ISO 8601 + granularity (Optional[int]): Desired time slice in seconds + + Returns: + list: Historic candle data. Example: + [ + [ time, low, high, open, close, volume ], + [ 1415398768, 0.32, 4.2, 0.35, 4.2, 12.3 ], + ... + ] + + """ + params = {} + if start is not None: + params['start'] = start + if end is not None: + params['end'] = end + if granularity is not None: + acceptedGrans = [60, 300, 900, 3600, 21600, 86400] + if granularity not in acceptedGrans: + raise ValueError( 'Specified granularity is {}, must be in approved values: {}'.format( + granularity, acceptedGrans) ) + + params['granularity'] = granularity + return self._send_message('get', + '/products/{}/candles'.format(product_id)) + + def get_product_24hr_stats(self, product_id): + """Get 24 hr stats for the product. + + Args: + product_id (str): Product + + Returns: + dict: 24 hour stats. Volume is in base currency units. + Open, high, low are in quote currency units. Example:: + { + "open": "34.19000000", + "high": "95.70000000", + "low": "7.06000000", + "volume": "2.41000000" + } + + """ + return self._send_message('get', + '/products/{}/stats'.format(product_id)) + + def get_currencies(self): + """List known currencies. + + Returns: + list: List of currencies. Example:: + [{ + "id": "BTC", + "name": "Bitcoin", + "min_size": "0.00000001" + }, { + "id": "USD", + "name": "United States Dollar", + "min_size": "0.01000000" + }] + + """ + return self._send_message('get', '/currencies') + + def get_time(self): + """Get the API server time. + + Returns: + dict: Server time in ISO and epoch format (decimal seconds + since Unix epoch). Example:: + { + "iso": "2015-01-07T23:47:25.201Z", + "epoch": 1420674445.201 + } + + """ + 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/cbpro/websocket_client.py b/cbpro/websocket_client.py new file mode 100644 index 00000000..861b7444 --- /dev/null +++ b/cbpro/websocket_client.py @@ -0,0 +1,160 @@ +# cbpro/WebsocketClient.py +# original author: Daniel Paquin +# mongo "support" added by Drew Rice +# +# +# Template object to receive messages from the Coinbase Websocket Feed + +from __future__ import print_function +import json +import base64 +import hmac +import hashlib +import time +from threading import Thread +from websocket import create_connection, WebSocketConnectionClosedException +from pymongo import MongoClient +from cbpro.cbpro_auth import get_auth_headers + + +class WebsocketClient(object): + 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="", channels=None): + self.url = url + self.products = products + self.channels = channels + self.type = message_type + self.stop = False + self.error = None + self.ws = None + self.thread = None + self.auth = auth + self.api_key = api_key + self.api_secret = api_secret + self.api_passphrase = api_passphrase + self.should_print = should_print + self.mongo_collection = mongo_collection + + def start(self): + def _go(): + self._connect() + self._listen() + self._disconnect() + + self.stop = False + self.on_open() + self.thread = Thread(target=_go) + self.thread.start() + + def _connect(self): + if self.products is None: + self.products = ["BTC-USD"] + elif not isinstance(self.products, list): + self.products = [self.products] + + if self.url[-1] == "/": + self.url = self.url[:-1] + + if self.channels is None: + sub_params = {'type': 'subscribe', 'product_ids': self.products} + 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' + 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-SIGN'] + sub_params['passphrase'] = auth_headers['CB-ACCESS-KEY'] + sub_params['timestamp'] = auth_headers['CB-ACCESS-TIMESTAMP'] + + self.ws = create_connection(self.url) + + self.ws.send(json.dumps(sub_params)) + + def _listen(self): + 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: + self.on_error(e) + except Exception as e: + self.on_error(e) + else: + self.on_message(msg) + + def _disconnect(self): + try: + if self.ws: + self.ws.close() + except WebSocketConnectionClosedException as e: + pass + + self.on_close() + + def close(self): + self.stop = True + self.thread.join() + + def on_open(self): + if self.should_print: + print("-- Subscribed! --\n") + + def on_close(self): + if self.should_print: + print("\n-- Socket Closed --") + + def on_message(self, msg): + if self.should_print: + print(msg) + if self.mongo_collection: # dump JSON to given mongo collection + self.mongo_collection.insert_one(msg) + + def on_error(self, e, data=None): + self.error = e + self.stop = True + print('{} - data: {}'.format(e, data)) + + +if __name__ == "__main__": + import sys + import cbpro + import time + + + class MyWebsocketClient(cbpro.WebsocketClient): + def on_open(self): + self.url = "wss://ws-feed.pro.coinbase.com/" + self.products = ["BTC-USD", "ETH-USD"] + self.message_count = 0 + print("Let's count the messages!") + + def on_message(self, msg): + print(json.dumps(msg, indent=4, sort_keys=True)) + self.message_count += 1 + + def on_close(self): + print("-- Goodbye! --") + + + wsClient = MyWebsocketClient() + wsClient.start() + print(wsClient.url, wsClient.products) + try: + while True: + print("\nMessageCount =", "%i \n" % wsClient.message_count) + time.sleep(1) + except KeyboardInterrupt: + wsClient.close() + + if wsClient.error: + sys.exit(1) + else: + sys.exit(0) diff --git a/pytest.ini b/pytest.ini index ab35919d..094d739e 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,3 @@ [pytest] -addopts = --cov gdax/ --cov-report=term-missing +addopts = --cov cbpro/ --cov-report=term-missing testpaths = tests \ No newline at end of file diff --git a/setup.py b/setup.py index 15a83d03..e68cbcc6 100644 --- a/setup.py +++ b/setup.py @@ -14,19 +14,25 @@ 'pytest', ] +with open("README.md", "r") as fh: + long_description = fh.read() + setup( - name='gdax', - version='1.0.6', + name='cbpro', + version='1.1.0', 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'], + 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', From a35362082932aa4dbaef80e61ccc5b89836c43bb Mon Sep 17 00:00:00 2001 From: Daniel Paquin Date: Sun, 19 Aug 2018 19:11:13 -0400 Subject: [PATCH 052/101] remove gdax/ --- gdax/.gitignore | 2 - gdax/__init__.py | 4 - gdax/authenticated_client.py | 1017 ---------------------------------- gdax/gdax_auth.py | 37 -- gdax/order_book.py | 298 ---------- gdax/public_client.py | 310 ----------- gdax/websocket_client.py | 163 ------ 7 files changed, 1831 deletions(-) delete mode 100644 gdax/.gitignore delete mode 100644 gdax/__init__.py delete mode 100644 gdax/authenticated_client.py delete mode 100644 gdax/gdax_auth.py delete mode 100644 gdax/order_book.py delete mode 100644 gdax/public_client.py delete mode 100644 gdax/websocket_client.py diff --git a/gdax/.gitignore b/gdax/.gitignore deleted file mode 100644 index 79c40e7a..00000000 --- a/gdax/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -*.pyc -FixClient.py \ 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 75509b4f..00000000 --- a/gdax/authenticated_client.py +++ /dev/null @@ -1,1017 +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): - """ Provides access to Private Endpoints on the GDAX API. - - All requests default to the live `api_url`: 'https://api.gdax.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 (GdaxAuth): Custom authentication handler for each request. - session (requests.Session): Persistent HTTP connection object. - """ - def __init__(self, key, b64secret, passphrase, - api_url="https://api.gdax.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 GDAX API. - """ - super(AuthenticatedClient, self).__init__(api_url) - self.auth = GdaxAuth(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 GDAX - * 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 place_order(self, product_id, side, order_type, **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', 'market', or 'stop') - **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. GDAX 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('tif') != '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('tif') 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 order_type == '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 GDAX-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 GDAX-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, side, 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') - side (str): Order side ('buy' or 'sell) - 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. - - """ - params = {'product_id': product_id, - 'side': side, - 'price': price, - 'order_type': 'stop', - '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} - data = json.dumps(params) - else: - data = None - return self._send_message('delete', '/orders', data=data) - - 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. - - 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 (Optional[str]): Limit list to this product_id - order_id (Optional[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" - }, - { - ... - } - ] - - """ - 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 GDAX. - - """ - 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 GDAX. - - 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 GDAX - trading accounts within your daily limits. Moving funds between - Coinbase and GDAX 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 GDAX - trading accounts within your daily limits. Moving funds between - Coinbase and GDAX 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', - 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') - - -class GdaxAuth(AuthBase): - # Provided by gdax: https://docs.gdax.com/#signing-a-message - def __init__(self, api_key, secret_key, passphrase): - self.api_key = api_key - self.secret_key = secret_key - self.passphrase = passphrase - - def __call__(self, request): - timestamp = str(time.time()) - message = timestamp + request.method + request.path_url + \ - (request.body or '') - message = message.encode('ascii') - hmac_key = base64.b64decode(self.secret_key) - signature = hmac.new(hmac_key, message, hashlib.sha256) - signature_b64 = base64.b64encode(signature.digest()) - request.headers.update({ - 'Content-Type': 'Application/json', - 'CB-ACCESS-SIGN': signature_b64, - 'CB-ACCESS-TIMESTAMP': timestamp, - 'CB-ACCESS-KEY': self.api_key, - 'CB-ACCESS-PASSPHRASE': self.passphrase - }) - return request diff --git a/gdax/gdax_auth.py b/gdax/gdax_auth.py deleted file mode 100644 index c4217331..00000000 --- a/gdax/gdax_auth.py +++ /dev/null @@ -1,37 +0,0 @@ -import hmac -import hashlib -import time -import base64 -from requests.auth import AuthBase - - -class GdaxAuth(AuthBase): - # Provided by gdax: https://docs.gdax.com/#signing-a-message - def __init__(self, api_key, secret_key, passphrase): - self.api_key = api_key - self.secret_key = secret_key - self.passphrase = passphrase - - def __call__(self, request): - timestamp = str(time.time()) - message = ''.join([timestamp, request.method, - request.path_url, (request.body or '')]) - request.headers.update(get_auth_headers(timestamp, message, - self.api_key, - self.secret_key, - self.passphrase)) - return request - - -def get_auth_headers(timestamp, message, api_key, secret_key, passphrase): - message = message.encode('ascii') - hmac_key = base64.b64decode(secret_key) - signature = hmac.new(hmac_key, message, hashlib.sha256) - signature_b64 = base64.b64encode(signature.digest()).decode('utf-8') - return { - 'Content-Type': 'Application/JSON', - 'CB-ACCESS-SIGN': signature_b64, - 'CB-ACCESS-TIMESTAMP': timestamp, - 'CB-ACCESS-KEY': api_key, - 'CB-ACCESS-PASSPHRASE': passphrase - } diff --git a/gdax/order_book.py b/gdax/order_book.py deleted file mode 100644 index d3ea56eb..00000000 --- a/gdax/order_book.py +++ /dev/null @@ -1,298 +0,0 @@ -# -# gdax/order_book.py -# David Caseria -# -# Live order book updated from the gdax Websocket Feed - -from sortedcontainers import SortedDict -from decimal import Decimal -import pickle - -from gdax.public_client import PublicClient -from gdax.websocket_client import WebsocketClient - - -class OrderBook(WebsocketClient): - 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() - self._sequence = -1 - self._log_to = log_to - 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! --") - - def reset_book(self): - self._asks = SortedDict() - self._bids = SortedDict() - res = self._client.get_product_order_book(product_id=self.product_id, level=3) - for bid in res['bids']: - self.add({ - 'id': bid[2], - 'side': 'buy', - 'price': Decimal(bid[0]), - 'size': Decimal(bid[1]) - }) - for ask in res['asks']: - self.add({ - 'id': ask[2], - 'side': 'sell', - 'price': Decimal(ask[0]), - 'size': Decimal(ask[1]) - }) - self._sequence = res['sequence'] - - def on_message(self, message): - if self._log_to: - pickle.dump(message, self._log_to) - - 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) - - 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 = { - 'id': order.get('order_id') or order['id'], - 'side': order['side'], - 'price': Decimal(order['price']), - 'size': Decimal(order.get('size') or order['remaining_size']) - } - if order['side'] == 'buy': - bids = self.get_bids(order['price']) - if bids is None: - bids = [order] - else: - bids.append(order) - self.set_bids(order['price'], bids) - else: - asks = self.get_asks(order['price']) - if asks is None: - asks = [order] - else: - asks.append(order) - self.set_asks(order['price'], asks) - - def remove(self, order): - price = Decimal(order['price']) - if order['side'] == 'buy': - bids = self.get_bids(price) - if bids is not None: - bids = [o for o in bids if o['id'] != order['order_id']] - if len(bids) > 0: - self.set_bids(price, bids) - else: - self.remove_bids(price) - else: - asks = self.get_asks(price) - if asks is not None: - asks = [o for o in asks if o['id'] != order['order_id']] - if len(asks) > 0: - self.set_asks(price, asks) - else: - self.remove_asks(price) - - def match(self, order): - size = Decimal(order['size']) - price = Decimal(order['price']) - - if order['side'] == 'buy': - bids = self.get_bids(price) - if not bids: - return - assert bids[0]['id'] == order['maker_order_id'] - if bids[0]['size'] == size: - self.set_bids(price, bids[1:]) - else: - bids[0]['size'] -= size - self.set_bids(price, bids) - else: - asks = self.get_asks(price) - if not asks: - return - assert asks[0]['id'] == order['maker_order_id'] - if asks[0]['size'] == size: - self.set_asks(price, asks[1:]) - else: - asks[0]['size'] -= size - self.set_asks(price, asks) - - def change(self, order): - try: - new_size = Decimal(order['new_size']) - except KeyError: - return - - try: - price = Decimal(order['price']) - except KeyError: - return - - if order['side'] == 'buy': - bids = self.get_bids(price) - if bids is None or not any(o['id'] == order['order_id'] for o in bids): - return - index = [b['id'] for b in bids].index(order['order_id']) - bids[index]['size'] = new_size - self.set_bids(price, bids) - else: - asks = self.get_asks(price) - if asks is None or not any(o['id'] == order['order_id'] for o in asks): - return - index = [a['id'] for a in asks].index(order['order_id']) - asks[index]['size'] = new_size - self.set_asks(price, asks) - - tree = self._asks if order['side'] == 'sell' else self._bids - node = tree.get(price) - - if node is None or not any(o['id'] == order['order_id'] for o in node): - return - - def get_current_ticker(self): - return self._current_ticker - - def get_current_book(self): - result = { - 'sequence': self._sequence, - 'asks': [], - 'bids': [], - } - for ask in self._asks: - try: - # There can be a race condition here, where a price point is removed - # between these two ops - this_ask = self._asks[ask] - except KeyError: - continue - for order in this_ask: - result['asks'].append([order['price'], order['size'], order['id']]) - for bid in self._bids: - try: - # There can be a race condition here, where a price point is removed - # between these two ops - this_bid = self._bids[bid] - except KeyError: - continue - - for order in this_bid: - result['bids'].append([order['price'], order['size'], order['id']]) - return result - - def get_ask(self): - return self._asks.peekitem(0)[0] - - def get_asks(self, price): - return self._asks.get(price) - - def remove_asks(self, price): - del self._asks[price] - - def set_asks(self, price, asks): - self._asks[price] = asks - - def get_bid(self): - return self._bids.peekitem(-1)[0] - - def get_bids(self, price): - return self._bids.get(price) - - def remove_bids(self, price): - del self._bids[price] - - def set_bids(self, price, bids): - self._bids[price] = bids - - -if __name__ == '__main__': - import sys - import time - import datetime as dt - - - class OrderBookConsole(OrderBook): - ''' Logs real-time changes to the bid-ask spread to the console ''' - - def __init__(self, product_id=None): - super(OrderBookConsole, self).__init__(product_id=product_id) - - # latest values of bid-ask spread - self._bid = None - self._ask = 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() - try: - while True: - time.sleep(10) - except KeyboardInterrupt: - order_book.close() - - if order_book.error: - sys.exit(1) - else: - sys.exit(0) diff --git a/gdax/public_client.py b/gdax/public_client.py deleted file mode 100644 index a1054f4a..00000000 --- a/gdax/public_client.py +++ /dev/null @@ -1,310 +0,0 @@ -# -# GDAX/PublicClient.py -# Daniel Paquin -# -# For public requests to the GDAX exchange - -import requests - - -class PublicClient(object): - """GDAX 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. - - """ - - def __init__(self, api_url='https://api.pro.coinbase.com', timeout=30): - """Create GDAX API public client. - - Args: - api_url (Optional[str]): API URL. Defaults to GDAX API. - - """ - self.url = api_url.rstrip('/') - self.auth = None - self.session = requests.Session() - - def get_products(self): - """Get a list of available currency pairs for trading. - - Returns: - list: Info about all currency pairs. Example:: - [ - { - "id": "BTC-USD", - "display_name": "BTC/USD", - "base_currency": "BTC", - "quote_currency": "USD", - "base_min_size": "0.01", - "base_max_size": "10000.00", - "quote_increment": "0.01" - } - ] - - """ - 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. - - The amount of detail shown can be customized with the `level` - parameter: - * 1: Only the best bid and ask - * 2: Top 50 bids and asks (aggregated) - * 3: Full order book (non aggregated) - - Level 1 and Level 2 are recommended for polling. For the most - up-to-date data, consider using the websocket stream. - - **Caution**: Level 3 is only recommended for users wishing to - maintain a full real-time order book using the websocket - stream. Abuse of Level 3 via polling will cause your access to - be limited or blocked. - - Args: - product_id (str): Product - level (Optional[int]): Order book level (1, 2, or 3). - Default is 1. - - Returns: - dict: Order book. Example for level 1:: - { - "sequence": "3", - "bids": [ - [ price, size, num-orders ], - ], - "asks": [ - [ price, size, num-orders ], - ] - } - - """ - 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. - - **Caution**: Polling is discouraged in favor of connecting via - the websocket stream and listening for match messages. - - Args: - product_id (str): Product - - Returns: - dict: Ticker info. Example:: - { - "trade_id": 4729088, - "price": "333.99", - "size": "0.193", - "bid": "333.98", - "ask": "333.99", - "volume": "5957.11914015", - "time": "2015-11-14T20:46:03.511254Z" - } - - """ - return self._send_message('get', - '/products/{}/ticker'.format(product_id)) - - 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 - after (Optional[str]): end time in ISO 8601 - limit (Optional[int]): the desired number of trades (can be more than 100, - automatically paginated) - results (Optional[list]): list of results that is used for the pagination - Returns: - list: Latest trades. Example:: - [{ - "time": "2014-11-07T22:19:28.578544Z", - "trade_id": 74, - "price": "10.00000000", - "size": "0.01000000", - "side": "buy" - }, { - "time": "2014-11-07T01:08:43.642366Z", - "trade_id": 73, - "price": "100.00000000", - "size": "0.01000000", - "side": "sell" - }] - """ - return self._send_paginated_message('/products/{}/trades' - .format(product_id)) - - def get_product_historic_rates(self, product_id, start=None, end=None, - granularity=None): - """Historic rates for a product. - - Rates are returned in grouped buckets based on requested - `granularity`. If start, end, and granularity aren't provided, - the exchange will assume some (currently unknown) default values. - - Historical rate data may be incomplete. No data is published for - intervals where there are no ticks. - - **Caution**: Historical rates should not be polled frequently. - If you need real-time information, use the trade and book - endpoints along with the websocket feed. - - The maximum number of data points for a single request is 200 - candles. If your selection of start/end time and granularity - will result in more than 200 data points, your request will be - rejected. If you wish to retrieve fine granularity data over a - larger time range, you will need to make multiple requests with - new start/end ranges. - - Args: - product_id (str): Product - start (Optional[str]): Start time in ISO 8601 - end (Optional[str]): End time in ISO 8601 - granularity (Optional[int]): Desired time slice in seconds - - Returns: - list: Historic candle data. Example: - [ - [ time, low, high, open, close, volume ], - [ 1415398768, 0.32, 4.2, 0.35, 4.2, 12.3 ], - ... - ] - - """ - params = {} - if start is not None: - params['start'] = start - if end is not None: - params['end'] = end - if granularity is not None: - acceptedGrans = [60, 300, 900, 3600, 21600, 86400] - if granularity not in acceptedGrans: - raise ValueError( 'Specified granularity is {}, must be in approved values: {}'.format( - granularity, acceptedGrans) ) - - params['granularity'] = granularity - return self._send_message('get', - '/products/{}/candles'.format(product_id)) - - def get_product_24hr_stats(self, product_id): - """Get 24 hr stats for the product. - - Args: - product_id (str): Product - - Returns: - dict: 24 hour stats. Volume is in base currency units. - Open, high, low are in quote currency units. Example:: - { - "open": "34.19000000", - "high": "95.70000000", - "low": "7.06000000", - "volume": "2.41000000" - } - - """ - return self._send_message('get', - '/products/{}/stats'.format(product_id)) - - def get_currencies(self): - """List known currencies. - - Returns: - list: List of currencies. Example:: - [{ - "id": "BTC", - "name": "Bitcoin", - "min_size": "0.00000001" - }, { - "id": "USD", - "name": "United States Dollar", - "min_size": "0.01000000" - }] - - """ - return self._send_message('get', '/currencies') - - def get_time(self): - """Get the API server time. - - Returns: - dict: Server time in ISO and epoch format (decimal seconds - since Unix epoch). Example:: - { - "iso": "2015-01-07T23:47:25.201Z", - "epoch": 1420674445.201 - } - - """ - 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 - # GDAX 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/gdax/websocket_client.py deleted file mode 100644 index 47000b12..00000000 --- a/gdax/websocket_client.py +++ /dev/null @@ -1,163 +0,0 @@ -# gdax/WebsocketClient.py -# original author: Daniel Paquin -# mongo "support" added by Drew Rice -# -# -# Template object to receive messages from the gdax Websocket Feed - -from __future__ import print_function -import json -import base64 -import hmac -import hashlib -import time -from threading import Thread -from websocket import create_connection, WebSocketConnectionClosedException -from pymongo import MongoClient -from gdax.gdax_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): - self.url = url - self.products = products - self.channels = channels - self.type = message_type - self.stop = False - self.error = None - self.ws = None - self.thread = None - self.auth = auth - self.api_key = api_key - self.api_secret = api_secret - self.api_passphrase = api_passphrase - self.should_print = should_print - self.mongo_collection = mongo_collection - - def start(self): - def _go(): - self._connect() - self._listen() - self._disconnect() - - self.stop = False - self.on_open() - self.thread = Thread(target=_go) - self.thread.start() - - def _connect(self): - if self.products is None: - self.products = ["BTC-USD"] - elif not isinstance(self.products, list): - self.products = [self.products] - - if self.url[-1] == "/": - self.url = self.url[:-1] - - if self.channels is None: - sub_params = {'type': 'subscribe', 'product_ids': self.products} - 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 - - self.ws = create_connection(self.url) - - self.ws.send(json.dumps(sub_params)) - - def _listen(self): - 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: - self.on_error(e) - except Exception as e: - self.on_error(e) - else: - self.on_message(msg) - - def _disconnect(self): - try: - if self.ws: - self.ws.close() - except WebSocketConnectionClosedException as e: - pass - - self.on_close() - - def close(self): - self.stop = True - self.thread.join() - - def on_open(self): - if self.should_print: - print("-- Subscribed! --\n") - - def on_close(self): - if self.should_print: - print("\n-- Socket Closed --") - - def on_message(self, msg): - if self.should_print: - print(msg) - if self.mongo_collection: # dump JSON to given mongo collection - self.mongo_collection.insert_one(msg) - - def on_error(self, e, data=None): - self.error = e - self.stop = True - print('{} - data: {}'.format(e, data)) - - -if __name__ == "__main__": - import sys - import gdax - import time - - - class MyWebsocketClient(gdax.WebsocketClient): - def on_open(self): - self.url = "wss://ws-feed.gdax.com/" - self.products = ["BTC-USD", "ETH-USD"] - self.message_count = 0 - print("Let's count the messages!") - - def on_message(self, msg): - print(json.dumps(msg, indent=4, sort_keys=True)) - self.message_count += 1 - - def on_close(self): - print("-- Goodbye! --") - - - wsClient = MyWebsocketClient() - wsClient.start() - print(wsClient.url, wsClient.products) - try: - while True: - print("\nMessageCount =", "%i \n" % wsClient.message_count) - time.sleep(1) - except KeyboardInterrupt: - wsClient.close() - - if wsClient.error: - sys.exit(1) - else: - sys.exit(0) From 75140cfe069fa451959e9b9b8ae01dc49a4b67f2 Mon Sep 17 00:00:00 2001 From: Daniel Paquin Date: Sun, 19 Aug 2018 19:21:41 -0400 Subject: [PATCH 053/101] modified reference in cbpro/order_book --- cbpro/order_book.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cbpro/order_book.py b/cbpro/order_book.py index d3ea56eb..790b7fce 100644 --- a/cbpro/order_book.py +++ b/cbpro/order_book.py @@ -1,15 +1,15 @@ # -# 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): From 7fc92bec07af1b395bee441afa1328c0bc3d7b44 Mon Sep 17 00:00:00 2001 From: Daniel Paquin Date: Sun, 19 Aug 2018 19:23:54 -0400 Subject: [PATCH 054/101] updated gdax references in tests/ --- tests/test_authenticated_client.py | 8 ++++---- tests/test_public_client.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_authenticated_client.py b/tests/test_authenticated_client.py index 96f034f7..4cfb4f5a 100644 --- a/tests/test_authenticated_client.py +++ b/tests/test_authenticated_client.py @@ -2,13 +2,13 @@ import json import time from itertools import islice -import gdax +import cbpro @pytest.fixture(scope='module') def dc(): """Dummy client for testing.""" - return gdax.AuthenticatedClient('test', 'test', 'test') + return cbpro.AuthenticatedClient('test', 'test', 'test') @pytest.mark.usefixtures('dc') @@ -45,8 +45,8 @@ def client(): provided in api_config.json""" with open('api_config.json') as file: api_config = json.load(file) - c = gdax.AuthenticatedClient( - api_url='https://api-public.sandbox.gdax.com', **api_config) + c = cbpro.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. diff --git a/tests/test_public_client.py b/tests/test_public_client.py index 81737cd5..b07faae1 100644 --- a/tests/test_public_client.py +++ b/tests/test_public_client.py @@ -1,6 +1,6 @@ import pytest from itertools import islice -import gdax +import cbpro import time import datetime from dateutil.relativedelta import relativedelta @@ -8,7 +8,7 @@ @pytest.fixture(scope='module') def client(): - return gdax.PublicClient() + return cbpro.PublicClient() @pytest.mark.usefixtures('client') From a32d5feebfa6265b8e8e993d25bcebd8470fd2ac Mon Sep 17 00:00:00 2001 From: Daniel Paquin Date: Sun, 19 Aug 2018 19:26:14 -0400 Subject: [PATCH 055/101] updated .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 7dcfef5a..77160174 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +*.DS_Store .idea *.pyc example.py @@ -7,7 +8,7 @@ dist/ *.rst venv/ *log.txt -gdax/__pycache__/ +cbpro/__pycache__/ .cache/ .coverage tests/__pycache__/ From 2f28abbdeeaa60932a24a93c6a7236e09da493ca Mon Sep 17 00:00:00 2001 From: Daniel Paquin Date: Sun, 19 Aug 2018 19:34:42 -0400 Subject: [PATCH 056/101] released cbpro 1.1.1 --- README.md | 4 ++-- setup.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 77c26e89..0f608130 100644 --- a/README.md +++ b/README.md @@ -363,11 +363,11 @@ python -m pytest ``` ## Change Log -*1.1* +*1.1.1* **Current PyPI release** - Refactor project for Coinbase Pro - Major overhaul on how pagination is handled -*1.0* **Current PyPI release** +*1.0* - The first release that is not backwards compatible - Refactored to follow PEP 8 Standards - Improved Documentation diff --git a/setup.py b/setup.py index e68cbcc6..30b21637 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ setup( name='cbpro', - version='1.1.0', + version='1.1.1', author='Daniel Paquin', author_email='dpaq34@gmail.com', license='MIT', From fa9edb937001a9db7bdb525b178ccd0c9eeec2ec Mon Sep 17 00:00:00 2001 From: "Thomas Chen, ASA" Date: Tue, 21 Aug 2018 20:54:32 +0900 Subject: [PATCH 057/101] Bug fix: params were not being sent to coinbase api. The call to _send_message was missing the params so the function was not respecting the datetime and granularity parameters when requesting data from coinbase api. So the request would always return whatever data coinbase decided to give to you. --- cbpro/public_client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cbpro/public_client.py b/cbpro/public_client.py index 52528964..e854a163 100644 --- a/cbpro/public_client.py +++ b/cbpro/public_client.py @@ -196,7 +196,8 @@ def get_product_historic_rates(self, product_id, start=None, end=None, params['granularity'] = granularity return self._send_message('get', - '/products/{}/candles'.format(product_id)) + '/products/{}/candles'.format(product_id), + params=params) def get_product_24hr_stats(self, product_id): """Get 24 hr stats for the product. From d69535e679cfb815484849e8d5e31b7a738a1d8b Mon Sep 17 00:00:00 2001 From: "Thomas Chen, ASA" Date: Tue, 21 Aug 2018 21:03:20 +0900 Subject: [PATCH 058/101] Websockets are in a stopped state at initialization. The self.stop variable should be True at initialization. --- cbpro/websocket_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cbpro/websocket_client.py b/cbpro/websocket_client.py index 861b7444..fc18c50e 100644 --- a/cbpro/websocket_client.py +++ b/cbpro/websocket_client.py @@ -24,7 +24,7 @@ def __init__(self, url="wss://ws-feed.pro.coinbase.com", products=None, message_ 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 From 2ece987d6ba60d003e680d9abf4d3651b604b013 Mon Sep 17 00:00:00 2001 From: alimcmaster1 Date: Wed, 22 Aug 2018 23:42:08 +0100 Subject: [PATCH 059/101] Configure travis CI --- .travis.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..9fad9096 --- /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 -r requirements.txt + +script: + - pytest From e98e6065a1debb509691c891337bd02577f4de59 Mon Sep 17 00:00:00 2001 From: Daniel Paquin Date: Thu, 23 Aug 2018 21:11:42 -0400 Subject: [PATCH 060/101] fixed tests references --- tests/test_authenticated_client.py | 6 +++--- tests/test_public_client.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_authenticated_client.py b/tests/test_authenticated_client.py index 4cfb4f5a..e7d574ec 100644 --- a/tests/test_authenticated_client.py +++ b/tests/test_authenticated_client.py @@ -2,13 +2,13 @@ import json import time from itertools import islice -import cbpro +from cbpro.authenticated_client import AuthenticatedClient @pytest.fixture(scope='module') def dc(): """Dummy client for testing.""" - return cbpro.AuthenticatedClient('test', 'test', 'test') + return AuthenticatedClient('test', 'test', 'test') @pytest.mark.usefixtures('dc') @@ -45,7 +45,7 @@ def client(): provided in api_config.json""" with open('api_config.json') as file: api_config = json.load(file) - c = cbpro.AuthenticatedClient( + 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 diff --git a/tests/test_public_client.py b/tests/test_public_client.py index b07faae1..10d2d1f7 100644 --- a/tests/test_public_client.py +++ b/tests/test_public_client.py @@ -1,14 +1,14 @@ import pytest -from itertools import islice -import cbpro 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 cbpro.PublicClient() + return PublicClient() @pytest.mark.usefixtures('client') From 9ccbc97652db1b7e6c7888b783722eee9f438104 Mon Sep 17 00:00:00 2001 From: Daniel Paquin Date: Thu, 23 Aug 2018 21:36:01 -0400 Subject: [PATCH 061/101] make cbpro visible to tests --- __init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/__init__.py b/__init__.py index e69de29b..a4dc3090 100644 --- a/__init__.py +++ b/__init__.py @@ -0,0 +1,3 @@ +# for tests +from cbpro.authenticated_client import AuthenticatedClient +from cbpro.public_client import PublicClient From 7121b1fc26121540eb6b4478c0649fc68a7c109e Mon Sep 17 00:00:00 2001 From: Daniel Paquin Date: Thu, 23 Aug 2018 21:59:07 -0400 Subject: [PATCH 062/101] updated testing environment --- __init__.py | 3 --- requirements.txt | 2 -- 2 files changed, 5 deletions(-) diff --git a/__init__.py b/__init__.py index a4dc3090..e69de29b 100644 --- a/__init__.py +++ b/__init__.py @@ -1,3 +0,0 @@ -# for tests -from cbpro.authenticated_client import AuthenticatedClient -from cbpro.public_client import PublicClient diff --git a/requirements.txt b/requirements.txt index 923b4555..a8378e03 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,3 @@ six==1.10.0 websocket-client==0.40.0 pymongo==3.5.1 pytest>=3.3.0 -pytest-cov>=2.5.0 -py-dateutil==2.2 From 722b36ba35311be80432c9f159cc11068eae3f58 Mon Sep 17 00:00:00 2001 From: Daniel Paquin Date: Thu, 23 Aug 2018 22:11:45 -0400 Subject: [PATCH 063/101] updated travis install --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 9fad9096..510a0621 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,7 @@ cache: pip python: 3.5 install: - - pip install -r requirements.txt + - python setup.py test script: - pytest From 20ab7af4c6d02fdd810da148d356a4e544df1150 Mon Sep 17 00:00:00 2001 From: Daniel Paquin Date: Thu, 23 Aug 2018 22:21:52 -0400 Subject: [PATCH 064/101] updated travis install --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 510a0621..3c6d78f1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,7 @@ cache: pip python: 3.5 install: - - python setup.py test + - pip install git+git://github.com/danpaquin/coinbasepro-python.git script: - pytest From 4e5d7d793bbfebbf988f65c002ca26515ea79f66 Mon Sep 17 00:00:00 2001 From: Daniel Paquin Date: Thu, 23 Aug 2018 22:24:08 -0400 Subject: [PATCH 065/101] reverted travis install --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 3c6d78f1..9fad9096 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,7 @@ cache: pip python: 3.5 install: - - pip install git+git://github.com/danpaquin/coinbasepro-python.git + - pip install -r requirements.txt script: - pytest From e14144633bf6b9f8cce746150c15e772de7d7d70 Mon Sep 17 00:00:00 2001 From: Daniel Paquin Date: Sat, 25 Aug 2018 17:32:01 -0400 Subject: [PATCH 066/101] updated websocket.sub_params mapping --- cbpro/websocket_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cbpro/websocket_client.py b/cbpro/websocket_client.py index fc18c50e..b598019f 100644 --- a/cbpro/websocket_client.py +++ b/cbpro/websocket_client.py @@ -65,8 +65,8 @@ def _connect(self): message = timestamp + 'GET' + '/users/self/verify' 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-SIGN'] - sub_params['passphrase'] = auth_headers['CB-ACCESS-KEY'] + 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) From 5669b608c91bb3a0504fe31ca59d92d4f8f38544 Mon Sep 17 00:00:00 2001 From: Daniel Paquin Date: Sun, 26 Aug 2018 10:58:11 -0400 Subject: [PATCH 067/101] pip update 1.1.2 --- README.md | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0f608130..cb24f291 100644 --- a/README.md +++ b/README.md @@ -363,7 +363,7 @@ python -m pytest ``` ## Change Log -*1.1.1* **Current PyPI release** +*1.1.2* **Current PyPI release** - Refactor project for Coinbase Pro - Major overhaul on how pagination is handled diff --git a/setup.py b/setup.py index 30b21637..da1dcdc0 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ setup( name='cbpro', - version='1.1.1', + version='1.1.2', author='Daniel Paquin', author_email='dpaq34@gmail.com', license='MIT', From 7d993fc5cdacbc45e04a54653c9df90105549e76 Mon Sep 17 00:00:00 2001 From: Daniel Paquin Date: Sun, 26 Aug 2018 16:50:45 -0400 Subject: [PATCH 068/101] raise error on get_fills --- cbpro/authenticated_client.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/cbpro/authenticated_client.py b/cbpro/authenticated_client.py index f45f31f5..42610cbe 100644 --- a/cbpro/authenticated_client.py +++ b/cbpro/authenticated_client.py @@ -585,6 +585,9 @@ def get_orders(self, product_id=None, status=None, **kwargs): 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. @@ -599,9 +602,6 @@ def get_fills(self, product_id=None, order_id=None, **kwargs): liquidity provider or liquidity taker. M indicates Maker and T indicates Taker. - As of 8/23/18 - Requests without either order_id or product_id - will be rejected - Args: product_id (str): Limit list to this product_id order_id (str): Limit list to this order_id @@ -628,6 +628,9 @@ def get_fills(self, product_id=None, order_id=None, **kwargs): ] """ + 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 From f6daf37cb36ebbd311ad60164b61d93ff68aab10 Mon Sep 17 00:00:00 2001 From: Daniel Paquin Date: Sun, 26 Aug 2018 17:01:15 -0400 Subject: [PATCH 069/101] drop JSON encoding for DELETE/cancel_all param functionality --- cbpro/authenticated_client.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cbpro/authenticated_client.py b/cbpro/authenticated_client.py index 42610cbe..862bccd0 100644 --- a/cbpro/authenticated_client.py +++ b/cbpro/authenticated_client.py @@ -477,10 +477,9 @@ def cancel_all(self, product_id=None): """ if product_id is not None: params = {'product_id': product_id} - data = json.dumps(params) else: - data = None - return self._send_message('delete', '/orders', data=data) + params = None + return self._send_message('delete', '/orders', params=params) def get_order(self, order_id): """ Get a single order by order id. From 6af1519cbf0d7aaf6acb947d462c16cabe5a8192 Mon Sep 17 00:00:00 2001 From: Chris B Date: Mon, 27 Aug 2018 12:08:59 +0200 Subject: [PATCH 070/101] updated authenticated_client.py For place_order method 'time_in_force' was written as 'tif' - which is not a standard argument for the coinbase pro API. --- cbpro/authenticated_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cbpro/authenticated_client.py b/cbpro/authenticated_client.py index 862bccd0..f8a9152f 100644 --- a/cbpro/authenticated_client.py +++ b/cbpro/authenticated_client.py @@ -234,10 +234,10 @@ def place_order(self, product_id, side, order_type, **kwargs): # Limit order checks if order_type == 'limit': if kwargs.get('cancel_after') is not None and \ - kwargs.get('tif') != 'GTT': + 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('tif') in \ + 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`') From 9860e8d435897c2e154d0cb1c2d1baff0e9d9efe Mon Sep 17 00:00:00 2001 From: alimcmaster1 Date: Mon, 27 Aug 2018 21:57:15 +0100 Subject: [PATCH 071/101] Get CI tests workings --- .coveragerc | 8 ++++++++ .travis.yml | 4 ++-- pytest.ini | 3 --- requirements.txt => requirements-dev.txt | 2 ++ tests/test_authenticated_client.py | 4 +++- tox.ini | 10 ++++++++++ 6 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 .coveragerc delete mode 100644 pytest.ini rename requirements.txt => requirements-dev.txt (74%) create mode 100644 tox.ini 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/.travis.yml b/.travis.yml index 9fad9096..3fa7df95 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,7 @@ cache: pip python: 3.5 install: - - pip install -r requirements.txt + - python setup.py install script: - - pytest + - python -m pytest tests/ diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 094d739e..00000000 --- a/pytest.ini +++ /dev/null @@ -1,3 +0,0 @@ -[pytest] -addopts = --cov cbpro/ --cov-report=term-missing -testpaths = tests \ No newline at end of file diff --git a/requirements.txt b/requirements-dev.txt similarity index 74% rename from requirements.txt rename to requirements-dev.txt index a8378e03..c01f220d 100644 --- a/requirements.txt +++ b/requirements-dev.txt @@ -4,3 +4,5 @@ six==1.10.0 websocket-client==0.40.0 pymongo==3.5.1 pytest>=3.3.0 +pytest>=3.3.0 +python-dateutil>=2.7.3 \ No newline at end of file diff --git a/tests/test_authenticated_client.py b/tests/test_authenticated_client.py index e7d574ec..ebe10497 100644 --- a/tests/test_authenticated_client.py +++ b/tests/test_authenticated_client.py @@ -23,6 +23,7 @@ def test_place_order_input_2(self, dc): r = dc.place_order('BTC-USD', 'buy', 'limit', cancel_after='123', tif='ABC') + @pytest.mark.skip("Needs fixing") def test_place_order_input_3(self, dc): with pytest.raises(ValueError): r = dc.place_order('BTC-USD', 'buy', 'limit', @@ -43,7 +44,7 @@ def test_place_order_input_5(self, dc): def client(): """Client that connects to sandbox API. Relies on authentication information provided in api_config.json""" - with open('api_config.json') as file: + 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) @@ -68,6 +69,7 @@ def client(): @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.""" 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" From 6edaaeb635758b5fbb0f68b94e5c8b106706048d Mon Sep 17 00:00:00 2001 From: alimcmaster1 Date: Mon, 27 Aug 2018 22:02:12 +0100 Subject: [PATCH 072/101] pip install dev --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 3fa7df95..ea36ead2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,7 @@ cache: pip python: 3.5 install: - - python setup.py install + - pip install -r requirements-dev.txt script: - python -m pytest tests/ From 285fc2ff1dae702ee0967aad8d52dbadb5689040 Mon Sep 17 00:00:00 2001 From: alimcmaster1 Date: Tue, 28 Aug 2018 22:25:34 +0100 Subject: [PATCH 073/101] Fix param name --- requirements-dev.txt | 1 - tests/test_authenticated_client.py | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index c01f220d..6ff2e3ec 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,5 +4,4 @@ six==1.10.0 websocket-client==0.40.0 pymongo==3.5.1 pytest>=3.3.0 -pytest>=3.3.0 python-dateutil>=2.7.3 \ No newline at end of file diff --git a/tests/test_authenticated_client.py b/tests/test_authenticated_client.py index ebe10497..c680b3c7 100644 --- a/tests/test_authenticated_client.py +++ b/tests/test_authenticated_client.py @@ -21,13 +21,12 @@ def test_place_order_input_1(self, dc): def test_place_order_input_2(self, dc): with pytest.raises(ValueError): r = dc.place_order('BTC-USD', 'buy', 'limit', - cancel_after='123', tif='ABC') + cancel_after='123', time_in_force='ABC') - @pytest.mark.skip("Needs fixing") def test_place_order_input_3(self, dc): with pytest.raises(ValueError): r = dc.place_order('BTC-USD', 'buy', 'limit', - post_only='true', tif='FOK') + post_only='true', time_in_force='FOK') def test_place_order_input_4(self, dc): with pytest.raises(ValueError): From e5079c3ae802a59150afe58b42006fa7ac5c45e2 Mon Sep 17 00:00:00 2001 From: Daniel Paquin Date: Tue, 28 Aug 2018 21:25:38 -0400 Subject: [PATCH 074/101] added travis icon to readme --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index cb24f291..611fee17 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +[![Build Status](https://travis-ci.org/danpaquin/coinbasepro-python.svg?branch=master)](https://travis-ci.org/danpaquin/coinbasepro-python) + # coinbasepro-python The Python client for the [Coinbase Pro API](https://docs.pro.coinbase.com/) (formerly known as the GDAX) From 3ec2c8cefb64481fb3cb56ca4a15bfc80a4f5f50 Mon Sep 17 00:00:00 2001 From: Daniel Paquin Date: Tue, 28 Aug 2018 21:27:31 -0400 Subject: [PATCH 075/101] updated travis icon on readme --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 611fee17..e7e5236c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ -[![Build Status](https://travis-ci.org/danpaquin/coinbasepro-python.svg?branch=master)](https://travis-ci.org/danpaquin/coinbasepro-python) - # 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) From 5af38452b1b0b9096c5c708252485e9bcbac714f Mon Sep 17 00:00:00 2001 From: Daniel Paquin Date: Tue, 28 Aug 2018 21:28:43 -0400 Subject: [PATCH 076/101] updated travis icon on readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index e7e5236c..75316412 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # 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) From a3039cb9d88648fef3369940769d06870817e31e Mon Sep 17 00:00:00 2001 From: Daniel Paquin Date: Wed, 5 Sep 2018 19:45:28 -0400 Subject: [PATCH 077/101] pip v1.1.3 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index da1dcdc0..198c909a 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ setup( name='cbpro', - version='1.1.2', + version='1.1.3', author='Daniel Paquin', author_email='dpaq34@gmail.com', license='MIT', From bd4e2eb1fb4a35c056d34ef5534fb4718dff7f45 Mon Sep 17 00:00:00 2001 From: Daniel Paquin Date: Wed, 5 Sep 2018 20:02:19 -0400 Subject: [PATCH 078/101] updated coinbase-withdraw endpoint --- cbpro/authenticated_client.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cbpro/authenticated_client.py b/cbpro/authenticated_client.py index f8a9152f..0c2dc329 100644 --- a/cbpro/authenticated_client.py +++ b/cbpro/authenticated_client.py @@ -867,7 +867,7 @@ def coinbase_withdraw(self, amount, currency, coinbase_account_id): params = {'amount': amount, 'currency': currency, 'coinbase_account_id': coinbase_account_id} - return self._send_message('post', '/withdrawals/coinbase', + return self._send_message('post', '/withdrawals/coinbase-account', data=json.dumps(params)) def crypto_withdraw(self, amount, currency, crypto_address): diff --git a/setup.py b/setup.py index 198c909a..38e457c1 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ setup( name='cbpro', - version='1.1.3', + version='1.1.4', author='Daniel Paquin', author_email='dpaq34@gmail.com', license='MIT', From 3923b528c6148094528d454cf032cd6b637e5e75 Mon Sep 17 00:00:00 2001 From: "Thomas Chen, ASA" Date: Tue, 30 Oct 2018 16:19:51 +0700 Subject: [PATCH 079/101] Fixes websocket dropped connections. This addresses #256. --- cbpro/websocket_client.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/cbpro/websocket_client.py b/cbpro/websocket_client.py index b598019f..f1b0f974 100644 --- a/cbpro/websocket_client.py +++ b/cbpro/websocket_client.py @@ -44,6 +44,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): @@ -73,14 +74,15 @@ def _connect(self): 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: @@ -96,11 +98,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): From e4ba196c3416e062ff5497a7d6e148ef874c422f Mon Sep 17 00:00:00 2001 From: Brenden Matthews Date: Mon, 26 Nov 2018 12:26:56 -0500 Subject: [PATCH 080/101] Update and fix dependencies. The way the dependencies are specified is too specific, and is causing a bunch of conflicts elsewhere. Let's update setup.py to do things the right way, then update requirements-dev.txt with `pip freeze > requirements-dev.txt`. --- .travis.yml | 2 +- requirements-dev.txt | 24 +++++++++++++++++------- setup.py | 12 ++++++++---- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/.travis.yml b/.travis.yml index ea36ead2..dec568c6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,7 @@ cache: pip python: 3.5 install: - - pip install -r requirements-dev.txt + - pip install .[test] script: - python -m pytest tests/ diff --git a/requirements-dev.txt b/requirements-dev.txt index 6ff2e3ec..7dfaedef 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,7 +1,17 @@ -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 -python-dateutil>=2.7.3 \ No newline at end of file +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.1 +websocket-client==0.54.0 diff --git a/setup.py b/setup.py index 38e457c1..ea047851 100644 --- a/setup.py +++ b/setup.py @@ -4,14 +4,15 @@ install_requires = [ 'sortedcontainers>=1.5.9', - 'requests==2.13.0', - 'six==1.10.0', - 'websocket-client==0.40.0', - 'pymongo==3.5.1' + 'requests>=2.13.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: @@ -27,6 +28,9 @@ packages=find_packages(), install_requires=install_requires, tests_require=tests_require, + 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", From 646e99bbb1e883e47cbbbb8d1d8096a8f467c1aa Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 20 Sep 2019 13:26:42 -0400 Subject: [PATCH 081/101] Fix OrderBook class and require channels argument for WebsocketClient (#381) * Fix broken OrderBook by automatically connecting to 'full' channel Resolves an issue where the OrderBook class does not receive data since the underlying WebsocketClient it uses does not connect to a channel on Coinbase's WS feed. The OrderBook class now automatically specifies the 'full' channel on init in the super() call to the WebsocketClient __init__ * Require WS channels to be specified for WebsocketClient Coinbase's API rejects connections that don't specify which channels to connect to. This commit changes the `channels` arg for the WebsocketClient class into a required keyword argument with no default value. See: https://www.python.org/dev/peps/pep-3102/ Closes #380 Closes #371 --- cbpro/order_book.py | 3 ++- cbpro/websocket_client.py | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/cbpro/order_book.py b/cbpro/order_book.py index 790b7fce..0f393d9b 100644 --- a/cbpro/order_book.py +++ b/cbpro/order_book.py @@ -14,7 +14,8 @@ class OrderBook(WebsocketClient): def __init__(self, product_id='BTC-USD', log_to=None): - super(OrderBook, self).__init__(products=product_id) + super(OrderBook, self).__init__( + products=product_id, channels=['full']) self._asks = SortedDict() self._bids = SortedDict() self._client = PublicClient() diff --git a/cbpro/websocket_client.py b/cbpro/websocket_client.py index f1b0f974..dd7d5c22 100644 --- a/cbpro/websocket_client.py +++ b/cbpro/websocket_client.py @@ -18,8 +18,21 @@ class WebsocketClient(object): - 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="", 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 From 9355a9c95ec064989a436e4b063559bc93bd6491 Mon Sep 17 00:00:00 2001 From: Daniel Paquin <13591584+danpaquin@users.noreply.github.com> Date: Fri, 20 Sep 2019 13:29:06 -0400 Subject: [PATCH 082/101] Upgrade urllib3 to fix security vulnerability urllib3==1.24.2 --- requirements-dev.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 7dfaedef..1d286529 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -13,5 +13,5 @@ python-dateutil==2.7.5 requests==2.20.1 six==1.11.0 sortedcontainers==2.1.0 -urllib3==1.24.1 +urllib3==1.24.2 websocket-client==0.54.0 From b6f3a85301d9cf364f53cf80a5f8f288ef73f701 Mon Sep 17 00:00:00 2001 From: Matthew Ingersoll Date: Sun, 5 Apr 2020 07:29:33 -0700 Subject: [PATCH 083/101] Add authenticated client get_fees method and test (#384) * Add authenticated client get_fees method This adds the ability to get your: * current maker & taker fee rates * 30-day trailing volume See: https://docs.pro.coinbase.com/#fees * Add authenticated client get_fees test --- cbpro/authenticated_client.py | 13 +++++++++++++ tests/test_authenticated_client.py | 4 ++++ 2 files changed, 17 insertions(+) diff --git a/cbpro/authenticated_client.py b/cbpro/authenticated_client.py index 0c2dc329..3013d423 100644 --- a/cbpro/authenticated_client.py +++ b/cbpro/authenticated_client.py @@ -995,3 +995,16 @@ def get_trailing_volume(self): """ 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/tests/test_authenticated_client.py b/tests/test_authenticated_client.py index c680b3c7..1643cc6e 100644 --- a/tests/test_authenticated_client.py +++ b/tests/test_authenticated_client.py @@ -186,3 +186,7 @@ def test_get_coinbase_accounts(self, client): 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 From 19cac25c51e592994cfa86ec487eef6a5c76718f Mon Sep 17 00:00:00 2001 From: adrielvieira Date: Sun, 5 Apr 2020 11:38:45 -0300 Subject: [PATCH 084/101] creates channels attributes based on products attribute in case channels are not provided in order to start connection (#393) Co-authored-by: Adriel Vieira --- cbpro/websocket_client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cbpro/websocket_client.py b/cbpro/websocket_client.py index dd7d5c22..b0ffeb7d 100644 --- a/cbpro/websocket_client.py +++ b/cbpro/websocket_client.py @@ -70,7 +70,8 @@ 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} From 316d27fc488ba755aef2974f2a9439d19b705c0b Mon Sep 17 00:00:00 2001 From: Martin Michlmayr Date: Tue, 5 May 2020 16:08:27 +0800 Subject: [PATCH 085/101] Fix typo in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 75316412..f5cc6997 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,7 @@ If you would like to receive real-time market updates, you must subscribe to the #### Subscribe to a single product ```python import cbpro -# Paramters are optional +# Parameters are optional wsClient = cbpro.WebsocketClient(url="wss://ws-feed.pro.coinbase.com", products="BTC-USD") # Do other stuff... wsClient.close() @@ -269,7 +269,7 @@ wsClient.close() #### Subscribe to multiple products ```python import cbpro -# Paramaters are optional +# Parameters are optional wsClient = cbpro.WebsocketClient(url="wss://ws-feed.pro.coinbase.com", products=["BTC-USD", "ETH-USD"]) # Do other stuff... From 1d2f993c8fb26e109579ded96db980741a545fd4 Mon Sep 17 00:00:00 2001 From: Jim Hribar Date: Sun, 19 Jan 2020 10:31:38 -0500 Subject: [PATCH 086/101] Corrections to README.md --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 75316412..9de7bd5b 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ 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.pro.coinbase.com/settings/api). +[account settings](https://pro.coinbase.com/settings/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. @@ -261,7 +261,9 @@ If you would like to receive real-time market updates, you must subscribe to the ```python import cbpro # Paramters are optional -wsClient = cbpro.WebsocketClient(url="wss://ws-feed.pro.coinbase.com", products="BTC-USD") +wsClient = cbpro.WebsocketClient(url="wss://ws-feed.pro.coinbase.com", + products="BTC-USD", + channels=["ticker"]) # Do other stuff... wsClient.close() ``` @@ -271,7 +273,8 @@ wsClient.close() import cbpro # Paramaters are optional wsClient = cbpro.WebsocketClient(url="wss://ws-feed.pro.coinbase.com", - products=["BTC-USD", "ETH-USD"]) + products=["BTC-USD", "ETH-USD"], + channels=["ticker"]) # Do other stuff... wsClient.close() ``` From fa83a31bf1c99e722aad29ea6b06837472508b3f Mon Sep 17 00:00:00 2001 From: Jim Hribar Date: Sun, 25 Oct 2020 06:42:56 -0400 Subject: [PATCH 087/101] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9de7bd5b..2baac448 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ 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://pro.coinbase.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. @@ -271,7 +271,7 @@ wsClient.close() #### Subscribe to multiple products ```python import cbpro -# Paramaters are optional +# Parameters are optional wsClient = cbpro.WebsocketClient(url="wss://ws-feed.pro.coinbase.com", products=["BTC-USD", "ETH-USD"], channels=["ticker"]) From 73b2de40684f22f3cdb700168da0977287066b94 Mon Sep 17 00:00:00 2001 From: JonasSteur Date: Sun, 28 Jul 2019 03:49:54 +0200 Subject: [PATCH 088/101] Fix creation of stop orders A 'stop' isn't an actual order type but is actually a special flavour of a limit order. The 'stop' and 'stop_price' params need to be set. There are 2 stop types: 1. loss (triggers at or below the stop price) -> this has to be a sell order to be valid (the Coinbase Pro will respond with an error otherwise) 2. entry (trigger at or above the stop price) -> has to be a buy Modified existing and added new test. --- cbpro/authenticated_client.py | 19 ++++++++++++++----- tests/test_authenticated_client.py | 14 +++++++++++--- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/cbpro/authenticated_client.py b/cbpro/authenticated_client.py index 3013d423..2d83d3b2 100644 --- a/cbpro/authenticated_client.py +++ b/cbpro/authenticated_client.py @@ -173,7 +173,7 @@ def get_account_holds(self, account_id, **kwargs): endpoint = '/accounts/{}/holds'.format(account_id) return self._send_paginated_message(endpoint, params=kwargs) - def place_order(self, product_id, side, order_type, **kwargs): + 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 @@ -183,7 +183,7 @@ def place_order(self, product_id, side, order_type, **kwargs): Args: product_id (str): Product to order (eg. 'BTC-USD') side (str): Order side ('buy' or 'sell) - order_type (str): Order type ('limit', 'market', or 'stop') + 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. @@ -243,7 +243,7 @@ def place_order(self, product_id, side, order_type, **kwargs): '`IOC` or `FOK`') # Market and stop order checks - if order_type == 'market' or order_type == 'stop': + 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).') @@ -392,7 +392,7 @@ def place_market_order(self, product_id, side, size=None, funds=None, return self.place_order(**params) - def place_stop_order(self, product_id, side, price, size=None, funds=None, + def place_stop_order(self, product_id, side, stop_type, price, size=None, funds=None, client_oid=None, stp=None, overdraft_enabled=None, @@ -402,6 +402,9 @@ def place_stop_order(self, product_id, side, price, size=None, funds=None, Args: product_id (str): Product to order (eg. 'BTC-USD') side (str): Order side ('buy' or 'sell) + 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`. @@ -421,10 +424,16 @@ def place_stop_order(self, product_id, side, price, size=None, funds=None, dict: Order details. See `place_order` for example. """ + + if (side == 'buy' and stop_type == 'loss') or (side == 'sell' and stop_type == 'entry'): + raise ValueError(f'Invalid stop order, combination of {side} and {stop_type} is not possible') + params = {'product_id': product_id, 'side': side, 'price': price, - 'order_type': 'stop', + 'order_type': None, + 'stop': stop_type, + 'stop_price': price, 'size': size, 'funds': funds, 'client_oid': client_oid, diff --git a/tests/test_authenticated_client.py b/tests/test_authenticated_client.py index 1643cc6e..98229191 100644 --- a/tests/test_authenticated_client.py +++ b/tests/test_authenticated_client.py @@ -130,13 +130,21 @@ def test_place_market_order(self, client): r = client.place_market_order('BTC-USD', 'buy', funds=100000) assert type(r) is dict - def test_place_stop_order(self, client): + @pytest.mark.parametrize('stop_type, side', [('entry', 'buy'), ('loss', 'sell')]) + def test_place_stop_order(self, client, stop_type, side): client.cancel_all() - r = client.place_stop_order('BTC-USD', 'buy', 1, 0.01) + r = client.place_stop_order('BTC-USD', side, stop_type, 100, 0.01) assert type(r) is dict - assert r['type'] == 'stop' + assert r['stop'] == stop_type + assert r['stop_price'] == '100.00000000' + 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', 'buy', 'loss', 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) From 1248de6d5ea23bb9bc1b75414fa19e758802e573 Mon Sep 17 00:00:00 2001 From: Mike Cardillo Date: Sat, 21 Nov 2020 15:52:19 -0800 Subject: [PATCH 089/101] Fix decimal places returned by server in test_place_stop_orer --- tests/test_authenticated_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_authenticated_client.py b/tests/test_authenticated_client.py index 98229191..7bd19bcf 100644 --- a/tests/test_authenticated_client.py +++ b/tests/test_authenticated_client.py @@ -136,7 +136,7 @@ def test_place_stop_order(self, client, stop_type, side): r = client.place_stop_order('BTC-USD', side, stop_type, 100, 0.01) assert type(r) is dict assert r['stop'] == stop_type - assert r['stop_price'] == '100.00000000' + assert r['stop_price'] == '100' assert r['type'] == 'limit' client.cancel_order(r['id']) From 73625bb3c650886a9b96796e126996bf6a9e1609 Mon Sep 17 00:00:00 2001 From: Mike Cardillo Date: Sat, 21 Nov 2020 16:13:12 -0800 Subject: [PATCH 090/101] Set 'side' directly in place_stop_order, instead of testing for valid side/stop_type pairs --- cbpro/authenticated_client.py | 11 +++++++---- tests/test_authenticated_client.py | 8 ++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/cbpro/authenticated_client.py b/cbpro/authenticated_client.py index 2d83d3b2..107afc4a 100644 --- a/cbpro/authenticated_client.py +++ b/cbpro/authenticated_client.py @@ -392,7 +392,7 @@ def place_market_order(self, product_id, side, size=None, funds=None, return self.place_order(**params) - def place_stop_order(self, product_id, side, stop_type, price, size=None, funds=None, + def place_stop_order(self, product_id, stop_type, price, size=None, funds=None, client_oid=None, stp=None, overdraft_enabled=None, @@ -401,7 +401,6 @@ def place_stop_order(self, product_id, side, stop_type, price, size=None, funds= Args: product_id (str): Product to order (eg. 'BTC-USD') - side (str): Order side ('buy' or 'sell) 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 @@ -425,8 +424,12 @@ def place_stop_order(self, product_id, side, stop_type, price, size=None, funds= """ - if (side == 'buy' and stop_type == 'loss') or (side == 'sell' and stop_type == 'entry'): - raise ValueError(f'Invalid stop order, combination of {side} and {stop_type} is not possible') + if stop_type == 'loss': + side = 'sell' + elif stop_type == 'entry': + side = 'buy' + else: + raise ValueError(f'Invalid stop_type for stop order: {stop_type}') params = {'product_id': product_id, 'side': side, diff --git a/tests/test_authenticated_client.py b/tests/test_authenticated_client.py index 7bd19bcf..40edfac8 100644 --- a/tests/test_authenticated_client.py +++ b/tests/test_authenticated_client.py @@ -130,10 +130,10 @@ def test_place_market_order(self, client): r = client.place_market_order('BTC-USD', 'buy', funds=100000) assert type(r) is dict - @pytest.mark.parametrize('stop_type, side', [('entry', 'buy'), ('loss', 'sell')]) - def test_place_stop_order(self, client, stop_type, side): + @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', side, stop_type, 100, 0.01) + 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' @@ -143,7 +143,7 @@ def test_place_stop_order(self, client, stop_type, side): def test_place_invalid_stop_order(self, client): client.cancel_all() with pytest.raises(ValueError): - client.place_stop_order('BTC-USD', 'buy', 'loss', 5.65, 0.01) + 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) From 8186cf2b5e55a3f48dece70dae367576e86824e4 Mon Sep 17 00:00:00 2001 From: Mike Cardillo Date: Sat, 21 Nov 2020 16:16:59 -0800 Subject: [PATCH 091/101] Updated new place_stop_order example in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b5251088..37872be3 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ auth_client.place_market_order(product_id='BTC-USD', ```python # Stop order. `funds` can be used instead of `size` here. auth_client.place_stop_order(product_id='BTC-USD', - side='buy', + stop_type='loss', price='200.00', size='0.01') ``` From 559dee43b5975f7cd9969d252e3c995ef3ebc49d Mon Sep 17 00:00:00 2001 From: freenancial Date: Wed, 1 Jul 2020 22:47:29 -0700 Subject: [PATCH 092/101] Add stablecoin conversion --- cbpro/authenticated_client.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/cbpro/authenticated_client.py b/cbpro/authenticated_client.py index 107afc4a..d92279c4 100644 --- a/cbpro/authenticated_client.py +++ b/cbpro/authenticated_client.py @@ -173,6 +173,33 @@ def get_account_holds(self, account_id, **kwargs): 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. From 45ed5dcc1c1862f2a89c74ee3092352527144098 Mon Sep 17 00:00:00 2001 From: Mike Cardillo Date: Sat, 21 Nov 2020 17:35:35 -0800 Subject: [PATCH 093/101] Added test_convert_stablecoin --- tests/test_authenticated_client.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_authenticated_client.py b/tests/test_authenticated_client.py index 40edfac8..36ae1beb 100644 --- a/tests/test_authenticated_client.py +++ b/tests/test_authenticated_client.py @@ -107,6 +107,14 @@ def test_get_account_holds(self, client): 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) From 5658b2212b0fe39dde18b792f34aeaf81dda6640 Mon Sep 17 00:00:00 2001 From: Mike Cardillo Date: Sat, 21 Nov 2020 18:20:51 -0800 Subject: [PATCH 094/101] Remove fstring from place_stop_order to make compatible with python < 3.6 --- cbpro/authenticated_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cbpro/authenticated_client.py b/cbpro/authenticated_client.py index d92279c4..f330377f 100644 --- a/cbpro/authenticated_client.py +++ b/cbpro/authenticated_client.py @@ -456,7 +456,7 @@ def place_stop_order(self, product_id, stop_type, price, size=None, funds=None, elif stop_type == 'entry': side = 'buy' else: - raise ValueError(f'Invalid stop_type for stop order: {stop_type}') + raise ValueError('Invalid stop_type for stop order: ' + stop_type) params = {'product_id': product_id, 'side': side, From 4e8b04989b230a9fa722e4dffc66492048930230 Mon Sep 17 00:00:00 2001 From: Vel Lesikov Date: Thu, 24 Jun 2021 18:30:12 -0700 Subject: [PATCH 095/101] Update requests version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ea047851..47e1ca9c 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ install_requires = [ 'sortedcontainers>=1.5.9', - 'requests>=2.13.0', + 'requests>=2.25.0', 'six>=1.10.0', 'websocket-client>=0.40.0', 'pymongo>=3.5.1', From 5d38d21f523885340cda8b8b3fbfb091ca49a099 Mon Sep 17 00:00:00 2001 From: Mike Cardillo Date: Mon, 27 Nov 2017 16:05:13 -0800 Subject: [PATCH 096/101] Removed WebsocketClient inheritance from OrderBook class This is meant to change the application workflow to only require one WebsocketClient, which can feed messages to data structures for further processing. --- cbpro/order_book.py | 144 +++++++++++++++++++++++--------------------- 1 file changed, 76 insertions(+), 68 deletions(-) diff --git a/cbpro/order_book.py b/cbpro/order_book.py index 0f393d9b..beeefc74 100644 --- a/cbpro/order_book.py +++ b/cbpro/order_book.py @@ -6,16 +6,15 @@ from sortedcontainers import SortedDict from decimal import Decimal +import Queue import pickle 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, channels=['full']) self._asks = SortedDict() self._bids = SortedDict() self._client = PublicClient() @@ -24,18 +23,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() @@ -57,33 +45,34 @@ 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.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 + 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() @@ -249,7 +238,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 ''' @@ -262,38 +250,58 @@ 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.websocket_queue = Queue.Queue() + + def on_message(self, msg): + self.websocket_queue.put(msg) + + order_book_btc = OrderBookConsole(product_id='BTC-USD') + order_book_eth = OrderBookConsole(product_id='ETH-USD') + + wsClient = WebsocketConsole() + wsClient.start() + time.sleep(10) try: while True: - time.sleep(10) + msg = wsClient.websocket_queue.get(timeout=15) + order_book_btc.process_message(msg) + order_book_eth.process_message(msg) except KeyboardInterrupt: - order_book.close() + wsClient.close() + except Exception: + pass - if order_book.error: + if wsClient.error: sys.exit(1) else: sys.exit(0) From bb66088ddeec2de63ac101d8a81c6c11a921da15 Mon Sep 17 00:00:00 2001 From: Mike Cardillo Date: Mon, 27 Nov 2017 17:07:36 -0800 Subject: [PATCH 097/101] Updated README to reflect OrderBook changes --- README.md | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 37872be3..b1a63c14 100644 --- a/README.md +++ b/README.md @@ -346,16 +346,30 @@ 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 cbpro, time -order_book = cbpro.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.websocket_queue = Queue.Queue() + def on_message(self, msg): + self.websocket_queue.put(msg) + +order_book_btc = cbpro.OrderBook(product_id='BTC-USD') +order_book_eth = cbpro.OrderBook(product_id='ETH-USD') +wsClient = myWebsocketClient() +wsClient.start() time.sleep(10) -order_book.close() +while True: + msg = wsClient.websocket_queue.get(timeout=15) + order_book.process_message(msg) + print(order_book_btc.get_ask()) + print(order_book_eth.get_bid()) ``` ### Testing From 002fdfe4ca5b6b124088fe124f6687abdd2cbae7 Mon Sep 17 00:00:00 2001 From: Mike Cardillo Date: Thu, 30 Nov 2017 13:50:13 -0800 Subject: [PATCH 098/101] Remove print from OrderBook.on_sequence_gap() --- cbpro/order_book.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/cbpro/order_book.py b/cbpro/order_book.py index beeefc74..035fa52f 100644 --- a/cbpro/order_book.py +++ b/cbpro/order_book.py @@ -76,9 +76,6 @@ def process_message(self, message): 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 = { From 53ab2f3a9630bfea0b54d3810eba1b0c81507942 Mon Sep 17 00:00:00 2001 From: Mike Cardillo Date: Thu, 30 Nov 2017 13:57:21 -0800 Subject: [PATCH 099/101] Updated contributors --- contributors.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 13147c0c923e3afe91f889ad3572391e7a158e71 Mon Sep 17 00:00:00 2001 From: Mike Cardillo Date: Sun, 10 Dec 2017 14:29:46 -0800 Subject: [PATCH 100/101] Removed reference to Queue in WebsocketConsole Removed to maintain backward compatability --- cbpro/order_book.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/cbpro/order_book.py b/cbpro/order_book.py index 035fa52f..1a8525e5 100644 --- a/cbpro/order_book.py +++ b/cbpro/order_book.py @@ -6,7 +6,6 @@ from sortedcontainers import SortedDict from decimal import Decimal -import Queue import pickle from cbpro.public_client import PublicClient @@ -277,22 +276,19 @@ def process_message(self, message): class WebsocketConsole(WebsocketClient): def on_open(self): self.products = ['BTC-USD', 'ETH-USD'] - self.websocket_queue = Queue.Queue() + self.order_book_btc = OrderBookConsole(product_id='BTC-USD') + self.order_book_eth = OrderBookConsole(product_id='ETH-USD') def on_message(self, msg): - self.websocket_queue.put(msg) - - order_book_btc = OrderBookConsole(product_id='BTC-USD') - order_book_eth = OrderBookConsole(product_id='ETH-USD') + self.order_book_btc.process_message(msg) + self.order_book_eth.process_message(msg) wsClient = WebsocketConsole() wsClient.start() time.sleep(10) try: while True: - msg = wsClient.websocket_queue.get(timeout=15) - order_book_btc.process_message(msg) - order_book_eth.process_message(msg) + pass except KeyboardInterrupt: wsClient.close() except Exception: From 789bbab7c5183d9d29f956b24a2ad5ae9705549c Mon Sep 17 00:00:00 2001 From: Mike Cardillo Date: Sun, 10 Dec 2017 21:27:46 -0800 Subject: [PATCH 101/101] Updated README to give example without Queue --- README.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b1a63c14..456d212e 100644 --- a/README.md +++ b/README.md @@ -356,20 +356,19 @@ import cbpro, time, Queue class myWebsocketClient(cbpro.WebsocketClient): def on_open(self): self.products = ['BTC-USD', 'ETH-USD'] - self.websocket_queue = Queue.Queue() + self.order_book_btc = OrderBookConsole(product_id='BTC-USD') + self.order_book_eth = OrderBookConsole(product_id='ETH-USD') def on_message(self, msg): - self.websocket_queue.put(msg) + self.order_book_btc.process_message(msg) + self.order_book_eth.process_message(msg) -order_book_btc = cbpro.OrderBook(product_id='BTC-USD') -order_book_eth = cbpro.OrderBook(product_id='ETH-USD') wsClient = myWebsocketClient() wsClient.start() time.sleep(10) while True: - msg = wsClient.websocket_queue.get(timeout=15) - order_book.process_message(msg) - print(order_book_btc.get_ask()) - print(order_book_eth.get_bid()) + print(wsClient.order_book_btc.get_ask()) + print(wsClient.order_book_eth.get_bid()) + time.sleep(1) ``` ### Testing