diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..84bb5aca --- /dev/null +++ b/.coveragerc @@ -0,0 +1,8 @@ +[run] +branch = True +source = + . +omit = + .tox/* + setup.py + tests/* diff --git a/.gitignore b/.gitignore index ad1bd98b..77160174 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +*.DS_Store .idea *.pyc example.py @@ -6,3 +7,9 @@ dist/ *.egg-info/ *.rst venv/ +*log.txt +cbpro/__pycache__/ +.cache/ +.coverage +tests/__pycache__/ +.pytest_cache diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..dec568c6 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,11 @@ +sudo: false +language: python +# cache package wheels (1 cache per python version) +cache: pip +python: 3.5 + +install: + - pip install .[test] + +script: + - python -m pytest tests/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..d6285fd5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# Docker environment for ubuntu, conda, python3.6 +# +# Usage: +# * build the image: +# coinbasepro-python$ docker build -t coinbasepro-python . +# * start the image: +# docker run -it coinbasepro-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 coinbasepro-python code path +ENV CODE_DIR /code/coinbasepro-python + +RUN mkdir -p $CODE_DIR +COPY . $CODE_DIR + +RUN cd $CODE_DIR && \ + pip install cbpro diff --git a/GDAX/AuthenticatedClient.py b/GDAX/AuthenticatedClient.py deleted file mode 100644 index 4aa2a027..00000000 --- a/GDAX/AuthenticatedClient.py +++ /dev/null @@ -1,289 +0,0 @@ -# -# GDAX/AuthenticatedClient.py -# Daniel Paquin -# -# For authenticated requests to the GDAX exchange - -import hmac, hashlib, time, requests, base64, json -from requests.auth import AuthBase -from GDAX.PublicClient import PublicClient - -class AuthenticatedClient(PublicClient): - def __init__(self, key, b64secret, passphrase, api_url="https://api.gdax.com", product_id="BTC-USD"): - self.url = api_url - if api_url[-1] == "/": - self.url = api_url[:-1] - self.productId = product_id - self.auth = GdaxAuth(key, b64secret, passphrase) - - def getAccount(self, accountId): - r = requests.get(self.url + '/accounts/' + accountId, auth=self.auth) - #r.raise_for_status() - return r.json() - - def getAccounts(self): - return self.getAccount('') - - def getAccountHistory(self, accountId): - list = [] - r = requests.get(self.url + '/accounts/%s/ledger' %accountId, auth=self.auth) - #r.raise_for_status() - list.append(r.json()) - if "cb-after" in r.headers: - self.historyPagination(accountId, list, r.headers["cb-after"]) - return list - - def historyPagination(self, accountId, list, after): - r = requests.get(self.url + '/accounts/%s/ledger?after=%s' %(accountId, str(after)), auth=self.auth) - #r.raise_for_status() - if r.json(): - list.append(r.json()) - if "cb-after" in r.headers: - self.historyPagination(accountId, list, r.headers["cb-after"]) - return list - - def getAccountHolds(self, accountId): - list = [] - r = requests.get(self.url + '/accounts/%s/holds' %accountId, auth=self.auth) - #r.raise_for_status() - list.append(r.json()) - if "cb-after" in r.headers: - self.holdsPagination(accountId, list, r.headers["cb-after"]) - return list - - def holdsPagination(self, accountId, list, after): - r = requests.get(self.url + '/accounts/%s/holds?after=%s' %(accountId, str(after)), auth=self.auth) - #r.raise_for_status() - if r.json(): - list.append(r.json()) - if "cb-after" in r.headers: - self.holdsPagination(accountId, list, r.headers["cb-after"]) - return list - - def buy(self, buyParams): - buyParams["side"] = "buy" - if not buyParams["product_id"]: - buyParams["product_id"] = self.productId - r = requests.post(self.url + '/orders', data=json.dumps(buyParams), auth=self.auth) - #r.raise_for_status() - return r.json() - - def sell(self, sellParams): - sellParams["side"] = "sell" - r = requests.post(self.url + '/orders', data=json.dumps(sellParams), auth=self.auth) - #r.raise_for_status() - return r.json() - - def cancelOrder(self, orderId): - r = requests.delete(self.url + '/orders/' + orderId, auth=self.auth) - #r.raise_for_status() - return r.json() - - def cancelAll(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.productId}), auth=self.auth) - #r.raise_for_status() - return r.json() - - def getOrder(self, orderId): - r = requests.get(self.url + '/orders/' + orderId, auth=self.auth) - #r.raise_for_status() - return r.json() - - def getOrders(self): - list = [] - r = requests.get(self.url + '/orders/', auth=self.auth) - #r.raise_for_status() - list.append(r.json()) - if 'cb-after' in r.headers: - self.paginateOrders(list, r.headers['cb-after']) - return list - - def paginateOrders(self, list, after): - r = requests.get(self.url + '/orders?after=%s' %str(after)) - #r.raise_for_status() - if r.json(): - list.append(r.json()) - if 'cb-after' in r.headers: - self.paginateOrders(list, r.headers['cb-after']) - return list - - def getFills(self, orderId='', productId='', before='', after='', limit=''): - list = [] - url = self.url + '/fills?' - if orderId: url += "order_id=%s&" %str(orderId) - if productId: url += "product_id=%s&" %(productId or self.productId) - if before: url += "before=%s&" %str(before) - if after: url += "after=%s&" %str(after) - if limit: url += "limit=%s&" %str(limit) - r = requests.get(url, auth=self.auth) - #r.raise_for_status() - list.append(r.json()) - if 'cb-after' in r.headers and limit is not len(r.json()): - return self.paginateFills(list, r.headers['cb-after'], orderId=orderId, productId=productId) - return list - - def paginateFills(self, list, after, orderId='', productId=''): - url = self.url + '/fills?after=%s&' % str(after) - if orderId: url += "order_id=%s&" % str(orderId) - if productId: url += "product_id=%s&" % (productId or self.productId) - r = requests.get(url, auth=self.auth) - #r.raise_for_status() - if r.json(): - list.append(r.json()) - if 'cb-after' in r.headers: - return self.paginateFills(list, r.headers['cb-after'], orderId=orderId, productId=productId) - return list - - def getFundings(self, list='', status='', after=''): - if not list: list = [] - url = self.url + '/funding?' - if status: url += "status=%s&" % str(status) - if after: url += 'after=%s&' % str(after) - r = requests.get(url, auth=self.auth) - #r.raise_for_status() - list.append(r.json()) - if 'cb-after' in r.headers: - return self.getFundings(list, status=status, after=r.headers['cb-after']) - return list - - def repayFunding(self, amount='', currency=''): - payload = { - "amount": amount, - "currency": currency #example: USD - } - r = requests.post(self.url + "/funding/repay", data=json.dumps(payload), auth=self.auth) - #r.raise_for_status() - return r.json() - - def marginTransfer(self, margin_profile_id="", type="",currency="",amount=""): - payload = { - "margin_profile_id": margin_profile_id, - "type": 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() - - def getPosition(self): - r = requests.get(self.url + "/position", auth=self.auth) - # r.raise_for_status() - return r.json() - - def closePosition(self, repay_only=""): - payload = { - "repay_only": repay_only or False - } - 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 - } - r = requests.post(self.url + "/deposits/payment-method", data=json.dumps(payload), auth=self.auth) - #r.raise_for_status() - return r.json() - - def coinbaseDeposit(self, amount="", currency="", coinbase_account_id=""): - payload = { - "amount": amount, - "currency": currency, - "coinbase_account_id": coinbase_account_id - } - r = requests.post(self.url + "/deposits/coinbase-account", data=json.dumps(payload), auth=self.auth) - # r.raise_for_status() - return r.json() - - def withdraw(self, amount="", currency="", payment_method_id=""): - payload = { - "amount": amount, - "currency": currency, - "payment_method_id": payment_method_id - } - r = requests.post(self.url + "/withdrawals/payment-method", data=json.dumps(payload), auth=self.auth) - #r.raise_for_status() - return r.json() - - def coinbaseWithdraw(self, amount="", currency="", coinbase_account_id=""): - payload = { - "amount": amount, - "currency": currency, - "coinbase_account_id": coinbase_account_id - } - r = requests.post(self.url + "/withdrawals/coinbase", data=json.dumps(payload), auth=self.auth) - # r.raise_for_status() - return r.json() - - def cryptoWithdraw(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() - - def getPaymentMethods(self): - r = requests.get(self.url + "/payment-methods", auth=self.auth) - #r.raise_for_status() - return r.json() - - def getCoinbaseAccounts(self): - r = requests.get(self.url + "/coinbase-accounts", auth=self.auth) - #r.raise_for_status() - return r.json() - - def createReport(self, type="", start_date="", end_date="", product_id="", account_id="", format="", email=""): - payload = { - "type": type, - "start_date": start_date, - "end_date": end_date, - "product_id": product_id, - "account_id": account_id, - "format": format, - "email": email - } - r = requests.post(self.url + "/reports", data=json.dumps(payload), auth=self.auth) - #r.raise_for_status() - return r.json() - - def getReport(self, reportId=""): - r = requests.get(self.url + "/reports/" + reportId, auth=self.auth) - #r.raise_for_status() - return r.json() - - def getTrailingVolume(self): - r = requests.get(self.url + "/users/self/trailing-volume", auth=self.auth) - #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 diff --git a/GDAX/OrderBook.py b/GDAX/OrderBook.py deleted file mode 100644 index bc210e39..00000000 --- a/GDAX/OrderBook.py +++ /dev/null @@ -1,189 +0,0 @@ -# -# GDAX/OrderBook.py -# David Caseria -# -# Live order book updated from the GDAX Websocket Feed - -from operator import itemgetter -from bintrees import RBTree - -from GDAX.PublicClient import PublicClient -from GDAX.WebsocketClient import WebsocketClient - - -class OrderBook(WebsocketClient): - - def __init__(self, product_id='BTC-USD'): - WebsocketClient.__init__(self, products=product_id) - self._asks = RBTree() - self._bids = RBTree() - self._client = PublicClient(product_id=product_id) - self._sequence = -1 - - def onMessage(self, message): - sequence = message['sequence'] - if self._sequence == -1: - self._asks = RBTree() - self._bids = RBTree() - res = self._client.getProductOrderBook(level=3) - for bid in res['bids']: - self.add({ - 'id': bid[2], - 'side': 'buy', - 'price': float(bid[0]), - 'size': float(bid[1]) - }) - for ask in res['asks']: - self.add({ - 'id': ask[2], - 'side': 'sell', - 'price': float(ask[0]), - 'size': float(ask[1]) - }) - self._sequence = res['sequence'] - - if sequence <= self._sequence: - return - elif sequence > self._sequence + 1: - self.close() - self.start() - return - - # print(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) - elif msg_type == 'change': - self.change(message) - self._sequence = sequence - - # 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]) - # print('bid: %f @ %f - ask: %f @ %f' % (bid_depth, bid, ask_depth, ask)) - - def add(self, order): - order = { - 'id': order['order_id'] if 'order_id' in order else order['id'], - 'side': order['side'], - 'price': float(order['price']), - 'size': float(order['size']) if 'size' in order else float(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 = float(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 = float(order['size']) - price = float(order['price']) - - if order['side'] == 'buy': - bids = self.get_bids(price) - 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) - 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): - new_size = float(order['new_size']) - price = float(order['price']) - - 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 = map(itemgetter('id'), 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 = map(itemgetter('id'), 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_ask(self): - return self._asks.min_key() - - def get_asks(self, price): - return self._asks.get(price) - - def remove_asks(self, price): - self._asks.remove(price) - - def set_asks(self, price, asks): - self._asks.insert(price, asks) - - def get_bid(self): - return self._bids.max_key() - - def get_bids(self, price): - return self._bids.get(price) - - def remove_bids(self, price): - self._bids.remove(price) - - def set_bids(self, price, bids): - self._bids.insert(price, bids) - - -if __name__ == '__main__': - import time - order_book = OrderBook() - order_book.start() - time.sleep(10) - order_book.close() diff --git a/GDAX/PublicClient.py b/GDAX/PublicClient.py deleted file mode 100644 index fda7f8a9..00000000 --- a/GDAX/PublicClient.py +++ /dev/null @@ -1,71 +0,0 @@ -# -# GDAX/PublicClient.py -# Daniel Paquin -# -# For public requests to the GDAX exchange - -import requests - -class PublicClient(): - def __init__(self, api_url="https://api.gdax.com", product_id="BTC-USD"): - self.url = api_url - if api_url[-1] == "/": - self.url = api_url[:-1] - self.productId = product_id - - def getProducts(self): - r = requests.get(self.url + '/products') - #r.raise_for_status() - return r.json() - - def getProductOrderBook(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/%s/book?level=%s' % (product or self.productId, str(level))) - #r.raise_for_status() - return r.json() - - def getProductTicker(self, json=None, product=''): - if type(json) is dict: - if "product" in json: product = json["product"] - r = requests.get(self.url + '/products/%s/ticker' % (product or self.productId)) - #r.raise_for_status() - return r.json() - - def getProductTrades(self, json=None, product=''): - if type(json) is dict: - if "product" in json: product = json["product"] - r = requests.get(self.url + '/products/%s/trades' % (product or self.productId)) - #r.raise_for_status() - return r.json() - - def getProductHistoricRates(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/%s/candles' % (product or self.productId), params=payload) - #r.raise_for_status() - return r.json() - - def getProduct24HrStats(self, json=None, product=''): - if type(json) is dict: - if "product" in json: product = json["product"] - r = requests.get(self.url + '/products/%s/stats' % (product or self.productId)) - #r.raise_for_status() - return r.json() - - def getCurrencies(self): - r = requests.get(self.url + '/currencies') - #r.raise_for_status() - return r.json() - - def getTime(self): - r = requests.get(self.url + '/time') - #r.raise_for_status() - return r.json() diff --git a/GDAX/WebsocketClient.py b/GDAX/WebsocketClient.py deleted file mode 100644 index bedad6a5..00000000 --- a/GDAX/WebsocketClient.py +++ /dev/null @@ -1,105 +0,0 @@ -# -# GDAX/WebsocketClient.py -# Daniel Paquin -# -# Template object to receive messages from the GDAX Websocket Feed - -from __future__ import print_function -import json -import time -from threading import Thread -from websocket import create_connection - -class WebsocketClient(object): - def __init__(self, url=None, products=None, type=None): - if url is None: - url = "wss://ws-feed.gdax.com" - - self.url = url - self.products = products - self.type = type or "subscribe" - self.stop = None - self.ws = None - self.thread = None - - def start(self): - def _go(): - self._connect() - self._listen() - - self.onOpen() - self.ws = create_connection(self.url) - 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] - - self.stop = False - sub_params = {'type': 'subscribe', 'product_ids': self.products} - self.ws.send(json.dumps(sub_params)) - if self.type is "heartbeat": - sub_params = {"type": "heartbeat", "on": True} - self.ws.send(json.dumps(sub_params)) - - def _listen(self): - while not self.stop: - try: - msg = json.loads(self.ws.recv()) - except Exception as e: - self.onError(e) - self.close() - else: - self.onMessage(msg) - - def close(self): - if self.stop is False: - if self.type is "heartbeat": - self.ws.send(json.dumps({"type": "heartbeat", "on": False})) - self.stop = True - self.onClose() - self.ws.close() - - def onOpen(self): - print("-- Subscribed! --\n") - - def onClose(self): - print("\n-- Socket Closed --") - - def onMessage(self, msg): - print(msg) - - def onError(self, e): - SystemError(e) - - -if __name__ == "__main__": - import GDAX, time - class myWebsocketClient(GDAX.WebsocketClient): - def onOpen(self): - self.url = "wss://ws-feed.gdax.com/" - self.products = ["BTC-USD", "ETH-USD"] - self.MessageCount = 0 - print ("Lets count the messages!") - - def onMessage(self, msg): - print ("Message type:", msg["type"], "\t@ %.3f" % float(msg["price"])) - self.MessageCount += 1 - - def onClose(self): - print ("-- Goodbye! --") - - wsClient = myWebsocketClient() - wsClient.start() - print(wsClient.url, wsClient.products) - # Do some logic with the data - while (wsClient.MessageCount < 500): - print ("\nMessageCount =", "%i \n") % wsClient.MessageCount - time.sleep(1) - wsClient.close() diff --git a/GDAX/__init__.py b/GDAX/__init__.py deleted file mode 100644 index 03d6b8a9..00000000 --- a/GDAX/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from GDAX.AuthenticatedClient import AuthenticatedClient -from GDAX.PublicClient import PublicClient -from GDAX.WebsocketClient import WebsocketClient -from GDAX.OrderBook import OrderBook diff --git a/README.md b/README.md index 47bd22cc..456d212e 100644 --- a/README.md +++ b/README.md @@ -1,300 +1,418 @@ -# GDAX-Python -The Python client for the [GDAX API](https://docs.gdax.com/) (formerly known as the Coinbase Exchange API) +# coinbasepro-python +[![Build Status](https://travis-ci.org/danpaquin/coinbasepro-python.svg?branch=master)](https://travis-ci.org/danpaquin/coinbasepro-python) + +The Python client for the [Coinbase Pro API](https://docs.pro.coinbase.com/) (formerly known as +the GDAX) ##### Provided under MIT License by Daniel Paquin. -*Note: this library may be subtly broken or buggy. The code is released under 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 CB Pro to learn +what and who is behind every tick. ## Under Development -- Test Scripts **on dev branch** -- Additional Functionality for *WebsocketClient*, including a real-time order book -- FIX API Client **Looking for support** +- Test Scripts +- Additional Functionality for the real-time order book +- FIX API Client **Looking for assistance** ## 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. See function docstrings for full syntax details. +**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 -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 -publicClient = GDAX.PublicClient() -# Set a default product -publicClient = GDAX.PublicClient(product_id="ETH-USD") +import cbpro +public_client = cbpro.PublicClient() ``` ### PublicClient Methods -- [getProducts](https://docs.gdax.com/#get-products) +- [get_products](https://docs.pro.coinbase.com//#get-products) ```python -publicClient.getProducts() +public_client.get_products() ``` -- [getProductOrderBook](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. -publicClient.getProductOrderBook() -# Get the order book at a specfific level. -publicClient.getProductOrderBook(level=1) +public_client.get_product_order_book('BTC-USD') +# Get the order book at a specific level. +public_client.get_product_order_book('BTC-USD', level=1) ``` -- [getProductTicker](https://docs.gdax.com/#get-product-ticker) +- [get_product_ticker](https://docs.pro.coinbase.com/#get-product-ticker) ```python -# Get the product ticker for the default product. -publicClient.getProductTicker() # Get the product ticker for a specific product. -publicClient.getProductTicker(product="ETH-USD") +public_client.get_product_ticker(product_id='ETH-USD') ``` -- [getProductTrades](https://docs.gdax.com/#get-trades) +- [get_product_trades](https://docs.pro.coinbase.com/#get-trades) (paginated) ```python -# Get the product trades for the default product. -publicClient.getProductTrades() # Get the product trades for a specific product. -publicClient.getProductTrades(product="ETH-USD") +# Returns a generator +public_client.get_product_trades(product_id='ETH-USD') ``` -- [getProductHistoricRates](https://docs.gdax.com/#get-historic-rates) +- [get_product_historic_rates](https://docs.pro.coinbase.com/#get-historic-rates) ```python -publicClient.getProductHistoricRates() -# To include other parameters, see official documentation: -publicClient.getProductHistoricRates(granularity=3000) +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) ``` -- [getProduct24HrStates](https://docs.gdax.com/#get-24hr-stats) +- [get_product_24hr_stats](https://docs.pro.coinbase.com/#get-24hr-stats) ```python -publicClient.getProduct24HrStats() +public_client.get_product_24hr_stats('ETH-USD') ``` -- [getCurrencies](https://docs.gdax.com/#get-currencies) +- [get_currencies](https://docs.pro.coinbase.com/#get-currencies) ```python -publicClient.getCurrencies() +public_client.get_currencies() ``` -- [getTime](https://docs.gdax.com/#time) -```python -publicClient.getTime() -``` - -#### *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!* - -- Both of these calls send the same request: +- [get_time](https://docs.pro.coinbase.com/#time) ```python -import GDAX -publicClient = GDAX.PublicClient() - -method1 = public.getProductHistoricRates(granularity='3000') - -params = { -'granularity': '3000' -} -method2 = public.getProductHistoricRates(params) - -# Both methods will send the same request, but not always return the same data if run in series. -print (method1, method2) +public_client.get_time() ``` - - ### Authenticated Client -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). The ```AuthenticatedClient``` inherits all methods from the ```PrivateClient``` class, so you will only need to initialize one if you are planning to integrate both into your script. + +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/profile/api). +The `AuthenticatedClient` inherits all methods from the `PublicClient` +class, so you will only need to initialize one if you are planning to +integrate both into your script. ```python -import GDAX -authClient = GDAX.AuthenticatedClient(key, b64secret, passphrase) -# Set a default product -authClient = GDAX.AuthenticatedClient(key, b64secret, passphrase, product_id="ETH-USD") -# Use the sandbox API (requires a different set of API access crudentials) -authClient = GDAX.AuthenticatedClient(key, b64secret, passphrase, api_url="https://api-public.sandbox.gdax.com") +import cbpro +auth_client = cbpro.AuthenticatedClient(key, b64secret, passphrase) +# Use the sandbox API (requires a different set of API access credentials) +auth_client = cbpro.AuthenticatedClient(key, b64secret, passphrase, + api_url="https://api-public.sandbox.pro.coinbase.com") ``` ### Pagination -Some calls are [paginated](https://docs.gdax.com/#pagination), meaning multiple calls must be made to receive the full set of data. Each page/request is a list of dict objects that are then appended to a master list, making it easy to navigate pages (e.g. ```request[0]``` would return the first page of data in the example below). *This feature is under consideration for redesign. Please provide feedback if you have issues or suggestions* +Some calls are [paginated](https://docs.pro.coinbase.com/#pagination), meaning multiple +calls must be made to receive the full set of data. The CB Pro Python API provides +an abstraction for paginated endpoints in the form of generators which provide a +clean interface for iteration but may make multiple HTTP requests behind the +scenes. The pagination options `before`, `after`, and `limit` may be supplied as +keyword arguments if desired, but aren't necessary for typical use cases. ```python -request = authClient.getFills(limit=100) -request[0] # Page 1 always present -request[1] # Page 2+ present only if the data exists +fills_gen = auth_client.get_fills() +# Get all fills (will possibly make multiple HTTP requests) +all_fills = list(fills_gen) +``` +One use case for pagination parameters worth pointing out is retrieving only +new data since the previous request. For the case of `get_fills()`, the +`trade_id` is the parameter used for indexing. By passing +`before=some_trade_id`, only fills more recent than that `trade_id` will be +returned. Note that when using `before`, a maximum of 100 entries will be +returned - this is a limitation of CB Pro. +```python +from itertools import islice +# Get 5 most recent fills +recent_fills = islice(auth_client.get_fills(), 5) +# Only fetch new fills since last call by utilizing `before` parameter. +new_fills = auth_client.get_fills(before=recent_fills[0]['trade_id']) ``` -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. ### AuthenticatedClient Methods -- [getAccounts](https://docs.gdax.com/#list-accounts) +- [get_accounts](https://docs.pro.coinbase.com/#list-accounts) ```python -authClient.getAccounts() +auth_client.get_accounts() ``` -- [getAccount](https://docs.gdax.com/#get-an-account) +- [get_account](https://docs.pro.coinbase.com/#get-an-account) ```python -authClient.getAccount("7d0f7d8e-dd34-4d9c-a846-06f431c381ba") +auth_client.get_account("7d0f7d8e-dd34-4d9c-a846-06f431c381ba") ``` -- [getAccountHistory](https://docs.gdax.com/#get-account-history) (paginated) +- [get_account_history](https://docs.pro.coinbase.com/#get-account-history) (paginated) ```python -authClient.getAccountHistory("7d0f7d8e-dd34-4d9c-a846-06f431c381ba") +# Returns generator: +auth_client.get_account_history("7d0f7d8e-dd34-4d9c-a846-06f431c381ba") ``` -- [getAccountHolds](https://docs.gdax.com/#get-holds) (paginated) +- [get_account_holds](https://docs.pro.coinbase.com/#get-holds) (paginated) ```python -authClient.getAccountHolds("7d0f7d8e-dd34-4d9c-a846-06f431c381ba") +# 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 -buyParams = { - 'price': '100.00', #USD - 'size': '0.01', #BTC - 'product_id': 'BTC-USD' -} -authClient.buy(buyParams) +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 -sellParams = { - 'price': '200.00', #USD - 'size': '0.01', #BTC - #product_id not needed if default is desired -} -authClient.sell(sellParams) +auth_client.sell(price='200.00', #USD + size='0.01', #BTC + order_type='limit', + product_id='BTC-USD') +``` +```python +# Limit order-specific method +auth_client.place_limit_order(product_id='BTC-USD', + side='buy', + price='200.00', + size='0.01') +``` +```python +# Place a market order by specifying amount of USD to use. +# Alternatively, `size` could be used to specify quantity in BTC amount. +auth_client.place_market_order(product_id='BTC-USD', + side='buy', + funds='100.00') +``` +```python +# Stop order. `funds` can be used instead of `size` here. +auth_client.place_stop_order(product_id='BTC-USD', + stop_type='loss', + price='200.00', + size='0.01') ``` -- [cancelOrder](https://docs.gdax.com/#cancel-an-order) +- [cancel_order](https://docs.pro.coinbase.com/#cancel-an-order) ```python -authClient.cancelOrder("d50ec984-77a8-460a-b958-66f114b0de9b") +auth_client.cancel_order("d50ec984-77a8-460a-b958-66f114b0de9b") ``` -- [cancelAll](https://docs.gdax.com/#cancel-an-order) +- [cancel_all](https://docs.pro.coinbase.com/#cancel-all) ```python -authClient.cancelOrder(productId='BTC-USD') +auth_client.cancel_all(product_id='BTC-USD') ``` -- [getOrders](https://docs.gdax.com/#list-orders) (paginated) +- [get_orders](https://docs.pro.coinbase.com/#list-orders) (paginated) ```python -authClient.getOrders() +# Returns generator: +auth_client.get_orders() ``` -- [getOrder](https://docs.gdax.com/#get-an-order) +- [get_order](https://docs.pro.coinbase.com/#get-an-order) ```python -authClient.getOrder("d50ec984-77a8-460a-b958-66f114b0de9b") +auth_client.get_order("d50ec984-77a8-460a-b958-66f114b0de9b") ``` -- [getFills](https://docs.gdax.com/#list-fills) (paginated) +- [get_fills](https://docs.pro.coinbase.com/#list-fills) (paginated) ```python -authClient.getFills() +# All return generators +auth_client.get_fills() # Get fills for a specific order -authClient.getFills(orderId="d50ec984-77a8-460a-b958-66f114b0de9b") +auth_client.get_fills(order_id="d50ec984-77a8-460a-b958-66f114b0de9b") # Get fills for a specific product -authClient.getFills(productId="ETH-BTC") +auth_client.get_fills(product_id="ETH-BTC") ``` -- [deposit & withdraw](https://docs.gdax.com/#depositwithdraw) +- [deposit & withdraw](https://docs.pro.coinbase.com/#depositwithdraw) ```python -# Deposit into GDAX from Coinbase Wallet depositParams = { 'amount': '25.00', # Currency determined by account specified 'coinbase_account_id': '60680c98bfe96c2601f27e9c' } -authClient.deposit(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' } -authClient.withdraw(withdrawParams) +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). +If you would like to receive real-time market updates, you must subscribe to the +[websocket feed](https://docs.pro.coinbase.com/#websocket-feed). #### Subscribe to a single product ```python -import GDAX -# Paramters are optional -wsClient = GDAX.WebsocketClient(url="wss://ws-feed.gdax.com", products="BTC-USD") +import cbpro + +# Parameters are optional +wsClient = cbpro.WebsocketClient(url="wss://ws-feed.pro.coinbase.com", + products="BTC-USD", + channels=["ticker"]) # Do other stuff... wsClient.close() ``` #### Subscribe to multiple products ```python -import GDAX -# Paramters are optional -wsClient = GDAX.WebsocketClient(url="wss://ws-feed.gdax.com", products=["BTC-USD", "ETH-USD"]) +import cbpro +# Parameters are optional +wsClient = cbpro.WebsocketClient(url="wss://ws-feed.pro.coinbase.com", + products=["BTC-USD", "ETH-USD"], + channels=["ticker"]) # Do other stuff... wsClient.close() ``` -### 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. +### WebsocketClient + Mongodb +The ```WebsocketClient``` now supports data gathering via MongoDB. Given a +MongoDB collection, the ```WebsocketClient``` will stream results directly into +the database collection. +```python +# import PyMongo and connect to a local, running Mongo instance +from pymongo import MongoClient +import cbpro +mongo_client = MongoClient('mongodb://localhost:27017/') + +# specify the database and collection +db = mongo_client.cryptocurrency_database +BTC_collection = db.BTC_collection + +# instantiate a WebsocketClient instance, with a Mongo collection as a parameter +wsClient = cbpro.WebsocketClient(url="wss://ws-feed.pro.coinbase.com", products="BTC-USD", + mongo_collection=BTC_collection, should_print=False) +wsClient.start() +``` -- 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. +### 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 initial parameters. +- onMessage - 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 -class myWebsocketClient(GDAX.WebsocketClient): - def onOpen(self): - self.url = "wss://ws-feed.gdax.com/" - self.products = ["BTC-USD", "ETH-USD"] - self.MessageCount = 0 +import cbpro, time +class myWebsocketClient(cbpro.WebsocketClient): + def on_open(self): + self.url = "wss://ws-feed.pro.coinbase.com/" + self.products = ["LTC-USD"] + self.message_count = 0 print("Lets count the messages!") - def onMessage(self, msg): - print("Message type:", msg["type"], "\t@ %.3f" % float(msg["price"])) - self.MessageCount += 1 - def onClose(self): + def on_message(self, msg): + 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): print("-- Goodbye! --") wsClient = myWebsocketClient() wsClient.start() print(wsClient.url, wsClient.products) -# Do some logic with the data -while (wsClient.MessageCount < 500): - print("\nMessageCount =", "%i \n" % wsClient.MessageCount) +while (wsClient.message_count < 500): + print ("\nmessage_count =", "{} \n".format(wsClient.message_count)) time.sleep(1) wsClient.close() ``` +## Testing +A test suite is under development. Tests for the authenticated client require a +set of sandbox API credentials. To provide them, rename +`api_config.json.example` in the tests folder to `api_config.json` and edit the +file accordingly. To run the tests, start in the project directory and run +``` +python -m pytest +``` ### Real-time OrderBook -The ```OrderBook``` subscribes to a websocket and keeps a real-time record of the orderbook for the product_id input. Please provide your feedback for future improvements. +The ```OrderBook``` is a convenient data structure to keep a real-time record of +the orderbook for the product_id input. It processes incoming messages from an +already existing WebsocketClient. Please provide your feedback for future +improvements. ```python -import GDAX, time -order_book = GDAX.OrderBook(product_id='BTC-USD') -order_book.start() +import cbpro, time, Queue +class myWebsocketClient(cbpro.WebsocketClient): + def on_open(self): + self.products = ['BTC-USD', 'ETH-USD'] + self.order_book_btc = OrderBookConsole(product_id='BTC-USD') + self.order_book_eth = OrderBookConsole(product_id='ETH-USD') + def on_message(self, msg): + self.order_book_btc.process_message(msg) + self.order_book_eth.process_message(msg) + +wsClient = myWebsocketClient() +wsClient.start() time.sleep(10) -order_book.close() +while True: + print(wsClient.order_book_btc.get_ask()) + print(wsClient.order_book_eth.get_bid()) + time.sleep(1) +``` + +### Testing +Unit tests are under development using the pytest framework. Contributions are +welcome! + +To run the full test suite, in the project +directory run: +```bash +python -m pytest ``` ## Change Log -*0.2.2* **Current PyPI release** +*1.1.2* **Current PyPI release** +- Refactor project for Coinbase Pro +- Major overhaul on how pagination is handled + +*1.0* +- The first release that is not backwards compatible +- Refactored to follow PEP 8 Standards +- Improved Documentation + +*0.3* +- Added crypto and LTC deposit & withdraw (undocumented). +- Added support for Margin trading (undocumented). +- Enhanced functionality of the WebsocketClient. +- Soft launch of the OrderBook (undocumented). +- Minor bug squashing & syntax improvements. + +*0.2.2* - 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 -- Merged Websocket updates to handle errors and reconnect +- Merged Websocket updates to handle errors and reconnect. *0.1.2* -- Updated JSON handling for increased compatibility among some users -- Added support for payment methods, reports, and coinbase user accounts -- Other compatibility updates +- Updated JSON handling for increased compatibility among some users. +- Added support for payment methods, reports, and Coinbase user accounts. +- Other compatibility updates. *0.1.1b2* -- Original PyPI Release +- Original PyPI Release. diff --git a/__init__.py b/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/GDAX/.gitignore b/cbpro/.gitignore similarity index 100% rename from GDAX/.gitignore rename to cbpro/.gitignore diff --git a/cbpro/__init__.py b/cbpro/__init__.py new file mode 100644 index 00000000..00937f59 --- /dev/null +++ b/cbpro/__init__.py @@ -0,0 +1,5 @@ +from cbpro.authenticated_client import AuthenticatedClient +from cbpro.public_client import PublicClient +from cbpro.websocket_client import WebsocketClient +from cbpro.order_book import OrderBook +from cbpro.cbpro_auth import CBProAuth diff --git a/cbpro/authenticated_client.py b/cbpro/authenticated_client.py new file mode 100644 index 00000000..f330377f --- /dev/null +++ b/cbpro/authenticated_client.py @@ -0,0 +1,1049 @@ +# +# cbpro/AuthenticatedClient.py +# Daniel Paquin +# +# For authenticated requests to the Coinbase exchange + +import hmac +import hashlib +import time +import requests +import base64 +import json +from requests.auth import AuthBase +from cbpro.public_client import PublicClient +from cbpro.cbpro_auth import CBProAuth + + +class AuthenticatedClient(PublicClient): + """ Provides access to Private Endpoints on the cbpro API. + + All requests default to the live `api_url`: 'https://api.pro.coinbase.com'. + To test your application using the sandbox modify the `api_url`. + + Attributes: + url (str): The api url for this client instance to use. + auth (CBProAuth): Custom authentication handler for each request. + session (requests.Session): Persistent HTTP connection object. + """ + def __init__(self, key, b64secret, passphrase, + api_url="https://api.pro.coinbase.com"): + """ Create an instance of the AuthenticatedClient class. + + Args: + key (str): Your API key. + b64secret (str): The secret key matching your API key. + passphrase (str): Passphrase chosen when setting up key. + api_url (Optional[str]): API URL. Defaults to cbpro API. + """ + super(AuthenticatedClient, self).__init__(api_url) + self.auth = CBProAuth(key, b64secret, passphrase) + self.session = requests.Session() + + def get_account(self, account_id): + """ Get information for a single account. + + Use this endpoint when you know the account_id. + + Args: + account_id (str): Account id for account you want to get. + + Returns: + dict: Account information. Example:: + { + "id": "a1b2c3d4", + "balance": "1.100", + "holds": "0.100", + "available": "1.00", + "currency": "USD" + } + """ + return self._send_message('get', '/accounts/' + account_id) + + def get_accounts(self): + """ Get a list of trading all accounts. + + When you place an order, the funds for the order are placed on + hold. They cannot be used for other orders or withdrawn. Funds + will remain on hold until the order is filled or canceled. The + funds on hold for each account will be specified. + + Returns: + list: Info about all accounts. Example:: + [ + { + "id": "71452118-efc7-4cc4-8780-a5e22d4baa53", + "currency": "BTC", + "balance": "0.0000000000000000", + "available": "0.0000000000000000", + "hold": "0.0000000000000000", + "profile_id": "75da88c5-05bf-4f54-bc85-5c775bd68254" + }, + { + ... + } + ] + + * Additional info included in response for margin accounts. + """ + return self.get_account('') + + def get_account_history(self, account_id, **kwargs): + """ List account activity. Account activity either increases or + decreases your account balance. + + Entry type indicates the reason for the account change. + * transfer: Funds moved to/from Coinbase to cbpro + * match: Funds moved as a result of a trade + * fee: Fee as a result of a trade + * rebate: Fee rebate as per our fee schedule + + If an entry is the result of a trade (match, fee), the details + field will contain additional information about the trade. + + Args: + account_id (str): Account id to get history of. + kwargs (dict): Additional HTTP request parameters. + + Returns: + list: History information for the account. Example:: + [ + { + "id": "100", + "created_at": "2014-11-07T08:19:27.028459Z", + "amount": "0.001", + "balance": "239.669", + "type": "fee", + "details": { + "order_id": "d50ec984-77a8-460a-b958-66f114b0de9b", + "trade_id": "74", + "product_id": "BTC-USD" + } + }, + { + ... + } + ] + """ + endpoint = '/accounts/{}/ledger'.format(account_id) + return self._send_paginated_message(endpoint, params=kwargs) + + def get_account_holds(self, account_id, **kwargs): + """ Get holds on an account. + + This method returns a generator which may make multiple HTTP requests + while iterating through it. + + Holds are placed on an account for active orders or + pending withdraw requests. + + As an order is filled, the hold amount is updated. If an order + is canceled, any remaining hold is removed. For a withdraw, once + it is completed, the hold is removed. + + The `type` field will indicate why the hold exists. The hold + type is 'order' for holds related to open orders and 'transfer' + for holds related to a withdraw. + + The `ref` field contains the id of the order or transfer which + created the hold. + + Args: + account_id (str): Account id to get holds of. + kwargs (dict): Additional HTTP request parameters. + + Returns: + generator(list): Hold information for the account. Example:: + [ + { + "id": "82dcd140-c3c7-4507-8de4-2c529cd1a28f", + "account_id": "e0b3f39a-183d-453e-b754-0c13e5bab0b3", + "created_at": "2014-11-06T10:34:47.123456Z", + "updated_at": "2014-11-06T10:40:47.123456Z", + "amount": "4.23", + "type": "order", + "ref": "0a205de4-dd35-4370-a285-fe8fc375a273", + }, + { + ... + } + ] + + """ + endpoint = '/accounts/{}/holds'.format(account_id) + return self._send_paginated_message(endpoint, params=kwargs) + + + def convert_stablecoin(self, amount, from_currency, to_currency): + """ Convert stablecoin. + + Args: + amount (Decimal): The amount to convert. + from_currency (str): Currency type (eg. 'USDC') + to_currency (str): Currency type (eg. 'USD'). + + Returns: + dict: Conversion details. Example:: + { + "id": "8942caee-f9d5-4600-a894-4811268545db", + "amount": "10000.00", + "from_account_id": "7849cc79-8b01-4793-9345-bc6b5f08acce", + "to_account_id": "105c3e58-0898-4106-8283-dc5781cda07b", + "from": "USD", + "to": "USDC" + } + + """ + params = {'from': from_currency, + 'to': to_currency, + 'amount': amount} + return self._send_message('post', '/conversions', data=json.dumps(params)) + + + def place_order(self, product_id, side, order_type=None, **kwargs): + """ Place an order. + + The three order types (limit, market, and stop) can be placed using this + method. Specific methods are provided for each order type, but if a + more generic interface is desired this method is available. + + Args: + product_id (str): Product to order (eg. 'BTC-USD') + side (str): Order side ('buy' or 'sell) + order_type (str): Order type ('limit', or 'market') + **client_oid (str): Order ID selected by you to identify your order. + This should be a UUID, which will be broadcast in the public + feed for `received` messages. + **stp (str): Self-trade prevention flag. cbpro doesn't allow self- + trading. This behavior can be modified with this flag. + Options: + 'dc' Decrease and Cancel (default) + 'co' Cancel oldest + 'cn' Cancel newest + 'cb' Cancel both + **overdraft_enabled (Optional[bool]): If true funding above and + beyond the account balance will be provided by margin, as + necessary. + **funding_amount (Optional[Decimal]): Amount of margin funding to be + provided for the order. Mutually exclusive with + `overdraft_enabled`. + **kwargs: Additional arguments can be specified for different order + types. See the limit/market/stop order methods for details. + + Returns: + dict: Order details. Example:: + { + "id": "d0c5340b-6d6c-49d9-b567-48c4bfca13d2", + "price": "0.10000000", + "size": "0.01000000", + "product_id": "BTC-USD", + "side": "buy", + "stp": "dc", + "type": "limit", + "time_in_force": "GTC", + "post_only": false, + "created_at": "2016-12-08T20:02:28.53864Z", + "fill_fees": "0.0000000000000000", + "filled_size": "0.00000000", + "executed_value": "0.0000000000000000", + "status": "pending", + "settled": false + } + + """ + # Margin parameter checks + if kwargs.get('overdraft_enabled') is not None and \ + kwargs.get('funding_amount') is not None: + raise ValueError('Margin funding must be specified through use of ' + 'overdraft or by setting a funding amount, but not' + ' both') + + # Limit order checks + if order_type == 'limit': + if kwargs.get('cancel_after') is not None and \ + kwargs.get('time_in_force') != 'GTT': + raise ValueError('May only specify a cancel period when time ' + 'in_force is `GTT`') + if kwargs.get('post_only') is not None and kwargs.get('time_in_force') in \ + ['IOC', 'FOK']: + raise ValueError('post_only is invalid when time in force is ' + '`IOC` or `FOK`') + + # Market and stop order checks + if order_type == 'market' or kwargs.get('stop'): + if not (kwargs.get('size') is None) ^ (kwargs.get('funds') is None): + raise ValueError('Either `size` or `funds` must be specified ' + 'for market/stop orders (but not both).') + + # Build params dict + params = {'product_id': product_id, + 'side': side, + 'type': order_type} + params.update(kwargs) + return self._send_message('post', '/orders', data=json.dumps(params)) + + def buy(self, product_id, order_type, **kwargs): + """Place a buy order. + + This is included to maintain backwards compatibility with older versions + of cbpro-Python. For maximum support from docstrings and function + signatures see the order type-specific functions place_limit_order, + place_market_order, and place_stop_order. + + Args: + product_id (str): Product to order (eg. 'BTC-USD') + order_type (str): Order type ('limit', 'market', or 'stop') + **kwargs: Additional arguments can be specified for different order + types. + + Returns: + dict: Order details. See `place_order` for example. + + """ + return self.place_order(product_id, 'buy', order_type, **kwargs) + + def sell(self, product_id, order_type, **kwargs): + """Place a sell order. + + This is included to maintain backwards compatibility with older versions + of cbpro-Python. For maximum support from docstrings and function + signatures see the order type-specific functions place_limit_order, + place_market_order, and place_stop_order. + + Args: + product_id (str): Product to order (eg. 'BTC-USD') + order_type (str): Order type ('limit', 'market', or 'stop') + **kwargs: Additional arguments can be specified for different order + types. + + Returns: + dict: Order details. See `place_order` for example. + + """ + return self.place_order(product_id, 'sell', order_type, **kwargs) + + def place_limit_order(self, product_id, side, price, size, + client_oid=None, + stp=None, + time_in_force=None, + cancel_after=None, + post_only=None, + overdraft_enabled=None, + funding_amount=None): + """Place a limit order. + + Args: + product_id (str): Product to order (eg. 'BTC-USD') + side (str): Order side ('buy' or 'sell) + price (Decimal): Price per cryptocurrency + size (Decimal): Amount of cryptocurrency to buy or sell + client_oid (Optional[str]): User-specified Order ID + stp (Optional[str]): Self-trade prevention flag. See `place_order` + for details. + time_in_force (Optional[str]): Time in force. Options: + 'GTC' Good till canceled + 'GTT' Good till time (set by `cancel_after`) + 'IOC' Immediate or cancel + 'FOK' Fill or kill + cancel_after (Optional[str]): Cancel after this period for 'GTT' + orders. Options are 'min', 'hour', or 'day'. + post_only (Optional[bool]): Indicates that the order should only + make liquidity. If any part of the order results in taking + liquidity, the order will be rejected and no part of it will + execute. + overdraft_enabled (Optional[bool]): If true funding above and + beyond the account balance will be provided by margin, as + necessary. + funding_amount (Optional[Decimal]): Amount of margin funding to be + provided for the order. Mutually exclusive with + `overdraft_enabled`. + + Returns: + dict: Order details. See `place_order` for example. + + """ + params = {'product_id': product_id, + 'side': side, + 'order_type': 'limit', + 'price': price, + 'size': size, + 'client_oid': client_oid, + 'stp': stp, + 'time_in_force': time_in_force, + 'cancel_after': cancel_after, + 'post_only': post_only, + 'overdraft_enabled': overdraft_enabled, + 'funding_amount': funding_amount} + params = dict((k, v) for k, v in params.items() if v is not None) + + return self.place_order(**params) + + def place_market_order(self, product_id, side, size=None, funds=None, + client_oid=None, + stp=None, + overdraft_enabled=None, + funding_amount=None): + """ Place market order. + + Args: + product_id (str): Product to order (eg. 'BTC-USD') + side (str): Order side ('buy' or 'sell) + size (Optional[Decimal]): Desired amount in crypto. Specify this or + `funds`. + funds (Optional[Decimal]): Desired amount of quote currency to use. + Specify this or `size`. + client_oid (Optional[str]): User-specified Order ID + stp (Optional[str]): Self-trade prevention flag. See `place_order` + for details. + overdraft_enabled (Optional[bool]): If true funding above and + beyond the account balance will be provided by margin, as + necessary. + funding_amount (Optional[Decimal]): Amount of margin funding to be + provided for the order. Mutually exclusive with + `overdraft_enabled`. + + Returns: + dict: Order details. See `place_order` for example. + + """ + params = {'product_id': product_id, + 'side': side, + 'order_type': 'market', + 'size': size, + 'funds': funds, + 'client_oid': client_oid, + 'stp': stp, + 'overdraft_enabled': overdraft_enabled, + 'funding_amount': funding_amount} + params = dict((k, v) for k, v in params.items() if v is not None) + + return self.place_order(**params) + + def place_stop_order(self, product_id, stop_type, price, size=None, funds=None, + client_oid=None, + stp=None, + overdraft_enabled=None, + funding_amount=None): + """ Place stop order. + + Args: + product_id (str): Product to order (eg. 'BTC-USD') + stop_type(str): Stop type ('entry' or 'loss') + loss: Triggers when the last trade price changes to a value at or below the stop_price. + entry: Triggers when the last trade price changes to a value at or above the stop_price + price (Decimal): Desired price at which the stop order triggers. + size (Optional[Decimal]): Desired amount in crypto. Specify this or + `funds`. + funds (Optional[Decimal]): Desired amount of quote currency to use. + Specify this or `size`. + client_oid (Optional[str]): User-specified Order ID + stp (Optional[str]): Self-trade prevention flag. See `place_order` + for details. + overdraft_enabled (Optional[bool]): If true funding above and + beyond the account balance will be provided by margin, as + necessary. + funding_amount (Optional[Decimal]): Amount of margin funding to be + provided for the order. Mutually exclusive with + `overdraft_enabled`. + + Returns: + dict: Order details. See `place_order` for example. + + """ + + if stop_type == 'loss': + side = 'sell' + elif stop_type == 'entry': + side = 'buy' + else: + raise ValueError('Invalid stop_type for stop order: ' + stop_type) + + params = {'product_id': product_id, + 'side': side, + 'price': price, + 'order_type': None, + 'stop': stop_type, + 'stop_price': price, + 'size': size, + 'funds': funds, + 'client_oid': client_oid, + 'stp': stp, + 'overdraft_enabled': overdraft_enabled, + 'funding_amount': funding_amount} + params = dict((k, v) for k, v in params.items() if v is not None) + + return self.place_order(**params) + + def cancel_order(self, order_id): + """ Cancel a previously placed order. + + If the order had no matches during its lifetime its record may + be purged. This means the order details will not be available + with get_order(order_id). If the order could not be canceled + (already filled or previously canceled, etc), then an error + response will indicate the reason in the message field. + + **Caution**: The order id is the server-assigned order id and + not the optional client_oid. + + Args: + order_id (str): The order_id of the order you want to cancel + + Returns: + list: Containing the order_id of cancelled order. Example:: + [ "c5ab5eae-76be-480e-8961-00792dc7e138" ] + + """ + return self._send_message('delete', '/orders/' + order_id) + + def cancel_all(self, product_id=None): + """ With best effort, cancel all open orders. + + Args: + product_id (Optional[str]): Only cancel orders for this + product_id + + Returns: + list: A list of ids of the canceled orders. Example:: + [ + "144c6f8e-713f-4682-8435-5280fbe8b2b4", + "debe4907-95dc-442f-af3b-cec12f42ebda", + "cf7aceee-7b08-4227-a76c-3858144323ab", + "dfc5ae27-cadb-4c0c-beef-8994936fde8a", + "34fecfbf-de33-4273-b2c6-baf8e8948be4" + ] + + """ + if product_id is not None: + params = {'product_id': product_id} + else: + params = None + return self._send_message('delete', '/orders', params=params) + + def get_order(self, order_id): + """ Get a single order by order id. + + If the order is canceled the response may have status code 404 + if the order had no matches. + + **Caution**: Open orders may change state between the request + and the response depending on market conditions. + + Args: + order_id (str): The order to get information of. + + Returns: + dict: Containing information on order. Example:: + { + "created_at": "2017-06-18T00:27:42.920136Z", + "executed_value": "0.0000000000000000", + "fill_fees": "0.0000000000000000", + "filled_size": "0.00000000", + "id": "9456f388-67a9-4316-bad1-330c5353804f", + "post_only": true, + "price": "1.00000000", + "product_id": "BTC-USD", + "settled": false, + "side": "buy", + "size": "1.00000000", + "status": "pending", + "stp": "dc", + "time_in_force": "GTC", + "type": "limit" + } + + """ + return self._send_message('get', '/orders/' + order_id) + + def get_orders(self, product_id=None, status=None, **kwargs): + """ List your current open orders. + + This method returns a generator which may make multiple HTTP requests + while iterating through it. + + Only open or un-settled orders are returned. As soon as an + order is no longer open and settled, it will no longer appear + in the default request. + + Orders which are no longer resting on the order book, will be + marked with the 'done' status. There is a small window between + an order being 'done' and 'settled'. An order is 'settled' when + all of the fills have settled and the remaining holds (if any) + have been removed. + + For high-volume trading it is strongly recommended that you + maintain your own list of open orders and use one of the + streaming market data feeds to keep it updated. You should poll + the open orders endpoint once when you start trading to obtain + the current state of any open orders. + + Args: + product_id (Optional[str]): Only list orders for this + product + status (Optional[list/str]): Limit list of orders to + this status or statuses. Passing 'all' returns orders + of all statuses. + ** Options: 'open', 'pending', 'active', 'done', + 'settled' + ** default: ['open', 'pending', 'active'] + + Returns: + list: Containing information on orders. Example:: + [ + { + "id": "d0c5340b-6d6c-49d9-b567-48c4bfca13d2", + "price": "0.10000000", + "size": "0.01000000", + "product_id": "BTC-USD", + "side": "buy", + "stp": "dc", + "type": "limit", + "time_in_force": "GTC", + "post_only": false, + "created_at": "2016-12-08T20:02:28.53864Z", + "fill_fees": "0.0000000000000000", + "filled_size": "0.00000000", + "executed_value": "0.0000000000000000", + "status": "open", + "settled": false + }, + { + ... + } + ] + + """ + params = kwargs + if product_id is not None: + params['product_id'] = product_id + if status is not None: + params['status'] = status + return self._send_paginated_message('/orders', params=params) + + def get_fills(self, product_id=None, order_id=None, **kwargs): + """ Get a list of recent fills. + + As of 8/23/18 - Requests without either order_id or product_id + will be rejected + + This method returns a generator which may make multiple HTTP requests + while iterating through it. + + Fees are recorded in two stages. Immediately after the matching + engine completes a match, the fill is inserted into our + datastore. Once the fill is recorded, a settlement process will + settle the fill and credit both trading counterparties. + + The 'fee' field indicates the fees charged for this fill. + + The 'liquidity' field indicates if the fill was the result of a + liquidity provider or liquidity taker. M indicates Maker and T + indicates Taker. + + Args: + product_id (str): Limit list to this product_id + order_id (str): Limit list to this order_id + kwargs (dict): Additional HTTP request parameters. + + Returns: + list: Containing information on fills. Example:: + [ + { + "trade_id": 74, + "product_id": "BTC-USD", + "price": "10.00", + "size": "0.01", + "order_id": "d50ec984-77a8-460a-b958-66f114b0de9b", + "created_at": "2014-11-07T22:19:28.578544Z", + "liquidity": "T", + "fee": "0.00025", + "settled": true, + "side": "buy" + }, + { + ... + } + ] + + """ + if (product_id is None) and (order_id is None): + raise ValueError('Either product_id or order_id must be specified.') + + params = {} + if product_id: + params['product_id'] = product_id + if order_id: + params['order_id'] = order_id + params.update(kwargs) + + return self._send_paginated_message('/fills', params=params) + + def get_fundings(self, status=None, **kwargs): + """ Every order placed with a margin profile that draws funding + will create a funding record. + + This method returns a generator which may make multiple HTTP requests + while iterating through it. + + Args: + status (list/str): Limit funding records to these statuses. + ** Options: 'outstanding', 'settled', 'rejected' + kwargs (dict): Additional HTTP request parameters. + + Returns: + list: Containing information on margin funding. Example:: + [ + { + "id": "b93d26cd-7193-4c8d-bfcc-446b2fe18f71", + "order_id": "b93d26cd-7193-4c8d-bfcc-446b2fe18f71", + "profile_id": "d881e5a6-58eb-47cd-b8e2-8d9f2e3ec6f6", + "amount": "1057.6519956381537500", + "status": "settled", + "created_at": "2017-03-17T23:46:16.663397Z", + "currency": "USD", + "repaid_amount": "1057.6519956381537500", + "default_amount": "0", + "repaid_default": false + }, + { + ... + } + ] + + """ + params = {} + if status is not None: + params['status'] = status + params.update(kwargs) + return self._send_paginated_message('/funding', params=params) + + def repay_funding(self, amount, currency): + """ Repay funding. Repays the older funding records first. + + Args: + amount (int): Amount of currency to repay + currency (str): The currency, example USD + + Returns: + Not specified by cbpro. + + """ + params = { + 'amount': amount, + 'currency': currency # example: USD + } + return self._send_message('post', '/funding/repay', + data=json.dumps(params)) + + def margin_transfer(self, margin_profile_id, transfer_type, currency, + amount): + """ Transfer funds between your standard profile and a margin profile. + + Args: + margin_profile_id (str): Margin profile ID to withdraw or deposit + from. + transfer_type (str): 'deposit' or 'withdraw' + currency (str): Currency to transfer (eg. 'USD') + amount (Decimal): Amount to transfer + + Returns: + dict: Transfer details. Example:: + { + "created_at": "2017-01-25T19:06:23.415126Z", + "id": "80bc6b74-8b1f-4c60-a089-c61f9810d4ab", + "user_id": "521c20b3d4ab09621f000011", + "profile_id": "cda95996-ac59-45a3-a42e-30daeb061867", + "margin_profile_id": "45fa9e3b-00ba-4631-b907-8a98cbdf21be", + "type": "deposit", + "amount": "2", + "currency": "USD", + "account_id": "23035fc7-0707-4b59-b0d2-95d0c035f8f5", + "margin_account_id": "e1d9862c-a259-4e83-96cd-376352a9d24d", + "margin_product_id": "BTC-USD", + "status": "completed", + "nonce": 25 + } + + """ + params = {'margin_profile_id': margin_profile_id, + 'type': transfer_type, + 'currency': currency, # example: USD + 'amount': amount} + return self._send_message('post', '/profiles/margin-transfer', + data=json.dumps(params)) + + def get_position(self): + """ Get An overview of your margin profile. + + Returns: + dict: Details about funding, accounts, and margin call. + + """ + return self._send_message('get', '/position') + + def close_position(self, repay_only): + """ Close position. + + Args: + repay_only (bool): Undocumented by cbpro. + + Returns: + Undocumented + + """ + params = {'repay_only': repay_only} + return self._send_message('post', '/position/close', + data=json.dumps(params)) + + def deposit(self, amount, currency, payment_method_id): + """ Deposit funds from a payment method. + + See AuthenticatedClient.get_payment_methods() to receive + information regarding payment methods. + + Args: + amount (Decmial): The amount to deposit. + currency (str): The type of currency. + payment_method_id (str): ID of the payment method. + + Returns: + dict: Information about the deposit. Example:: + { + "id": "593533d2-ff31-46e0-b22e-ca754147a96a", + "amount": "10.00", + "currency": "USD", + "payout_at": "2016-08-20T00:31:09Z" + } + + """ + params = {'amount': amount, + 'currency': currency, + 'payment_method_id': payment_method_id} + return self._send_message('post', '/deposits/payment-method', + data=json.dumps(params)) + + def coinbase_deposit(self, amount, currency, coinbase_account_id): + """ Deposit funds from a coinbase account. + + You can move funds between your Coinbase accounts and your cbpro + trading accounts within your daily limits. Moving funds between + Coinbase and cbpro is instant and free. + + See AuthenticatedClient.get_coinbase_accounts() to receive + information regarding your coinbase_accounts. + + Args: + amount (Decimal): The amount to deposit. + currency (str): The type of currency. + coinbase_account_id (str): ID of the coinbase account. + + Returns: + dict: Information about the deposit. Example:: + { + "id": "593533d2-ff31-46e0-b22e-ca754147a96a", + "amount": "10.00", + "currency": "BTC", + } + + """ + params = {'amount': amount, + 'currency': currency, + 'coinbase_account_id': coinbase_account_id} + return self._send_message('post', '/deposits/coinbase-account', + data=json.dumps(params)) + + def withdraw(self, amount, currency, payment_method_id): + """ Withdraw funds to a payment method. + + See AuthenticatedClient.get_payment_methods() to receive + information regarding payment methods. + + Args: + amount (Decimal): The amount to withdraw. + currency (str): Currency type (eg. 'BTC') + payment_method_id (str): ID of the payment method. + + Returns: + dict: Withdraw details. Example:: + { + "id":"593533d2-ff31-46e0-b22e-ca754147a96a", + "amount": "10.00", + "currency": "USD", + "payout_at": "2016-08-20T00:31:09Z" + } + + """ + params = {'amount': amount, + 'currency': currency, + 'payment_method_id': payment_method_id} + return self._send_message('post', '/withdrawals/payment-method', + data=json.dumps(params)) + + def coinbase_withdraw(self, amount, currency, coinbase_account_id): + """ Withdraw funds to a coinbase account. + + You can move funds between your Coinbase accounts and your cbpro + trading accounts within your daily limits. Moving funds between + Coinbase and cbpro is instant and free. + + See AuthenticatedClient.get_coinbase_accounts() to receive + information regarding your coinbase_accounts. + + Args: + amount (Decimal): The amount to withdraw. + currency (str): The type of currency (eg. 'BTC') + coinbase_account_id (str): ID of the coinbase account. + + Returns: + dict: Information about the deposit. Example:: + { + "id":"593533d2-ff31-46e0-b22e-ca754147a96a", + "amount":"10.00", + "currency": "BTC", + } + + """ + params = {'amount': amount, + 'currency': currency, + 'coinbase_account_id': coinbase_account_id} + return self._send_message('post', '/withdrawals/coinbase-account', + data=json.dumps(params)) + + def crypto_withdraw(self, amount, currency, crypto_address): + """ Withdraw funds to a crypto address. + + Args: + amount (Decimal): The amount to withdraw + currency (str): The type of currency (eg. 'BTC') + crypto_address (str): Crypto address to withdraw to. + + Returns: + dict: Withdraw details. Example:: + { + "id":"593533d2-ff31-46e0-b22e-ca754147a96a", + "amount":"10.00", + "currency": "BTC", + } + + """ + params = {'amount': amount, + 'currency': currency, + 'crypto_address': crypto_address} + return self._send_message('post', '/withdrawals/crypto', + data=json.dumps(params)) + + def get_payment_methods(self): + """ Get a list of your payment methods. + + Returns: + list: Payment method details. + + """ + return self._send_message('get', '/payment-methods') + + def get_coinbase_accounts(self): + """ Get a list of your coinbase accounts. + + Returns: + list: Coinbase account details. + + """ + return self._send_message('get', '/coinbase-accounts') + + def create_report(self, report_type, start_date, end_date, product_id=None, + account_id=None, report_format='pdf', email=None): + """ Create report of historic information about your account. + + The report will be generated when resources are available. Report status + can be queried via `get_report(report_id)`. + + Args: + report_type (str): 'fills' or 'account' + start_date (str): Starting date for the report in ISO 8601 + end_date (str): Ending date for the report in ISO 8601 + product_id (Optional[str]): ID of the product to generate a fills + report for. Required if account_type is 'fills' + account_id (Optional[str]): ID of the account to generate an account + report for. Required if report_type is 'account'. + report_format (Optional[str]): 'pdf' or 'csv'. Default is 'pdf'. + email (Optional[str]): Email address to send the report to. + + Returns: + dict: Report details. Example:: + { + "id": "0428b97b-bec1-429e-a94c-59232926778d", + "type": "fills", + "status": "pending", + "created_at": "2015-01-06T10:34:47.000Z", + "completed_at": undefined, + "expires_at": "2015-01-13T10:35:47.000Z", + "file_url": undefined, + "params": { + "start_date": "2014-11-01T00:00:00.000Z", + "end_date": "2014-11-30T23:59:59.000Z" + } + } + + """ + params = {'type': report_type, + 'start_date': start_date, + 'end_date': end_date, + 'format': report_format} + if product_id is not None: + params['product_id'] = product_id + if account_id is not None: + params['account_id'] = account_id + if email is not None: + params['email'] = email + + return self._send_message('post', '/reports', + data=json.dumps(params)) + + def get_report(self, report_id): + """ Get report status. + + Use to query a specific report once it has been requested. + + Args: + report_id (str): Report ID + + Returns: + dict: Report details, including file url once it is created. + + """ + return self._send_message('get', '/reports/' + report_id) + + def get_trailing_volume(self): + """ Get your 30-day trailing volume for all products. + + This is a cached value that's calculated every day at midnight UTC. + + Returns: + list: 30-day trailing volumes. Example:: + [ + { + "product_id": "BTC-USD", + "exchange_volume": "11800.00000000", + "volume": "100.00000000", + "recorded_at": "1973-11-29T00:05:01.123456Z" + }, + { + ... + } + ] + + """ + return self._send_message('get', '/users/self/trailing-volume') + + def get_fees(self): + """ Get your maker & taker fee rates and 30-day trailing volume. + + Returns: + dict: Fee information and USD volume:: + { + "maker_fee_rate": "0.0015", + "taker_fee_rate": "0.0025", + "usd_volume": "25000.00" + } + """ + return self._send_message('get', '/fees') diff --git a/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..1a8525e5 --- /dev/null +++ b/cbpro/order_book.py @@ -0,0 +1,300 @@ +# +# cbpro/order_book.py +# David Caseria +# +# Live order book updated from the Coinbase Websocket Feed + +from sortedcontainers import SortedDict +from decimal import Decimal +import pickle + +from cbpro.public_client import PublicClient +from cbpro.websocket_client import WebsocketClient + + +class OrderBook(object): + def __init__(self, product_id='BTC-USD', log_to=None): + 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 + self.product_id = product_id + + 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 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 + + 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() + + 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 process_message(self, message): + if message.get('product_id') == self.product_id: + super(OrderBookConsole, self).process_message(message) + + try: + # Calculate newest bid-ask spread + bid = self.get_bid() + bids = self.get_bids(bid) + bid_depth = sum([b['size'] for b in bids]) + ask = self.get_ask() + asks = self.get_asks(ask) + ask_depth = sum([a['size'] for a in asks]) + + if self._bid == bid and self._ask == ask and self._bid_depth == bid_depth and self._ask_depth == ask_depth: + # If there are no changes to the bid-ask spread since the last update, no need to print + pass + else: + # If there are differences, update the cache + self._bid = bid + self._ask = ask + self._bid_depth = bid_depth + self._ask_depth = ask_depth + print('{} {} bid: {:.3f} @ {:.2f}\task: {:.3f} @ {:.2f}'.format( + dt.datetime.now(), self.product_id, bid_depth, bid, ask_depth, ask)) + except Exception: + pass + + class WebsocketConsole(WebsocketClient): + def on_open(self): + self.products = ['BTC-USD', 'ETH-USD'] + self.order_book_btc = OrderBookConsole(product_id='BTC-USD') + self.order_book_eth = OrderBookConsole(product_id='ETH-USD') + + def on_message(self, msg): + self.order_book_btc.process_message(msg) + self.order_book_eth.process_message(msg) + + wsClient = WebsocketConsole() + wsClient.start() + time.sleep(10) + try: + while True: + pass + except KeyboardInterrupt: + wsClient.close() + except Exception: + pass + + if wsClient.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..e854a163 --- /dev/null +++ b/cbpro/public_client.py @@ -0,0 +1,311 @@ +# +# 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), + params=params) + + 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..b0ffeb7d --- /dev/null +++ b/cbpro/websocket_client.py @@ -0,0 +1,179 @@ +# 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="", + # Make channels a required keyword-only argument; see pep3102 + *, + # Channel options: ['ticker', 'user', 'matches', 'level2', 'full'] + channels): + self.url = url + self.products = products + self.channels = channels + self.type = message_type + self.stop = True + 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.keepalive = Thread(target=self._keepalive) + 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: + self.channels = [{"name": "ticker", "product_ids": [product_id for product_id in self.products]}] + sub_params = {'type': 'subscribe', 'product_ids': self.products, 'channels': self.channels} + else: + sub_params = {'type': 'subscribe', 'product_ids': self.products, 'channels': self.channels} + + if self.auth: + timestamp = str(time.time()) + message = timestamp + 'GET' + '/users/self/verify' + auth_headers = get_auth_headers(timestamp, message, self.api_key, self.api_secret, self.api_passphrase) + sub_params['signature'] = auth_headers['CB-ACCESS-SIGN'] + sub_params['key'] = auth_headers['CB-ACCESS-KEY'] + sub_params['passphrase'] = auth_headers['CB-ACCESS-PASSPHRASE'] + sub_params['timestamp'] = auth_headers['CB-ACCESS-TIMESTAMP'] + + self.ws = create_connection(self.url) + + self.ws.send(json.dumps(sub_params)) + + def _keepalive(self, interval=30): + while self.ws.connected: + self.ws.ping("keepalive") + time.sleep(interval) + + def _listen(self): + self.keepalive.start() + while not self.stop: + try: + 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 + finally: + self.keepalive.join() + + self.on_close() + + def close(self): + 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): + 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/contributors.txt b/contributors.txt index 66b5190d..3a6695b6 100644 --- a/contributors.txt +++ b/contributors.txt @@ -2,3 +2,6 @@ Daniel J Paquin Leonard Lin Jeff Gibson David Caseria +Paul Mestemaker +Drew Rice +Mike Cardillo \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..1d286529 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,17 @@ +atomicwrites==1.2.1 +attrs==18.2.0 +cbpro==1.1.4 +certifi==2018.10.15 +chardet==3.0.4 +idna==2.7 +more-itertools==4.3.0 +pluggy==0.8.0 +py==1.7.0 +pymongo==3.7.2 +pytest==4.0.1 +python-dateutil==2.7.5 +requests==2.20.1 +six==1.11.0 +sortedcontainers==2.1.0 +urllib3==1.24.2 +websocket-client==0.54.0 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 40bc1f53..00000000 --- a/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -bintrees==2.0.7 -requests==2.13.0 -six==1.10.0 -websocket-client==0.40.0 diff --git a/setup.py b/setup.py index 738028a3..47e1ca9c 100644 --- a/setup.py +++ b/setup.py @@ -3,24 +3,40 @@ from setuptools import setup, find_packages install_requires = [ - 'bintrees==2.0.7', - 'requests==2.13.0', - 'six==1.10.0', - 'websocket-client==0.40.0', + 'sortedcontainers>=1.5.9', + 'requests>=2.25.0', + 'six>=1.10.0', + 'websocket-client>=0.40.0', + 'pymongo>=3.5.1', ] +tests_require = [ + 'pytest', + 'python-dateutil>=2.7.5', + ] + +with open("README.md", "r") as fh: + long_description = fh.read() + setup( - name = 'GDAX', - version = '0.2.2', - author = 'Daniel Paquin', - author_email = 'dpaq34@gmail.com', + name='cbpro', + version='1.1.4', + author='Daniel Paquin', + author_email='dpaq34@gmail.com', license='MIT', - url = 'https://github.com/danpaquin/GDAX-Python', - packages = find_packages(), - install_requires = install_requires, - 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'], + url='https://github.com/danpaquin/coinbasepro-python', + 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", + download_url='https://github.com/danpaquin/coinbasepro-python/archive/master.zip', + keywords=['gdax', 'gdax-api', 'orderbook', 'trade', 'bitcoin', 'ethereum', 'BTC', 'ETH', 'client', 'api', 'wrapper', + 'exchange', 'crypto', 'currency', 'trading', 'trading-api', 'coinbase', 'pro', 'prime', 'coinbasepro'], classifiers=[ 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', diff --git a/tests/api_config.json.example b/tests/api_config.json.example new file mode 100644 index 00000000..12d90097 --- /dev/null +++ b/tests/api_config.json.example @@ -0,0 +1,3 @@ +{"passphrase": "passhere", + "b64secret": "secrethere", + "key": "key here"} \ No newline at end of file diff --git a/tests/test_authenticated_client.py b/tests/test_authenticated_client.py new file mode 100644 index 00000000..36ae1beb --- /dev/null +++ b/tests/test_authenticated_client.py @@ -0,0 +1,208 @@ +import pytest +import json +import time +from itertools import islice +from cbpro.authenticated_client import AuthenticatedClient + + +@pytest.fixture(scope='module') +def dc(): + """Dummy client for testing.""" + return AuthenticatedClient('test', 'test', 'test') + + +@pytest.mark.usefixtures('dc') +class TestAuthenticatedClientSyntax(object): + def test_place_order_input_1(self, dc): + with pytest.raises(ValueError): + r = dc.place_order('BTC-USD', 'buy', 'market', + overdraft_enabled='true', funding_amount=10) + + def test_place_order_input_2(self, dc): + with pytest.raises(ValueError): + r = dc.place_order('BTC-USD', 'buy', 'limit', + cancel_after='123', time_in_force='ABC') + + def test_place_order_input_3(self, dc): + with pytest.raises(ValueError): + r = dc.place_order('BTC-USD', 'buy', 'limit', + post_only='true', time_in_force='FOK') + + def test_place_order_input_4(self, dc): + with pytest.raises(ValueError): + r = dc.place_order('BTC-USD', 'buy', 'market', + size=None, funds=None) + + def test_place_order_input_5(self, dc): + with pytest.raises(ValueError): + r = dc.place_order('BTC-USD', 'buy', 'market', + size=1, funds=1) + + +@pytest.fixture(scope='module') +def client(): + """Client that connects to sandbox API. Relies on authentication information + provided in api_config.json""" + with open('api_config.json.example') as file: + api_config = json.load(file) + c = AuthenticatedClient( + api_url='https://api-public.sandbox.pro.coinbase.com', **api_config) + + # Set up account with deposits and orders. Do this by depositing from + # the Coinbase USD wallet, which has a fixed value of > $10,000. + # + # Only deposit if the balance is below some nominal amount. The + # exchange seems to freak out if you run up your account balance. + coinbase_accounts = c.get_coinbase_accounts() + account_info = [x for x in coinbase_accounts + if x['name'] == 'USD Wallet'][0] + account_usd = account_info['id'] + if float(account_info['balance']) < 70000: + c.coinbase_deposit(10000, 'USD', account_usd) + # Place some orders to generate history + c.place_limit_order('BTC-USD', 'buy', 1, 0.01) + c.place_limit_order('BTC-USD', 'buy', 2, 0.01) + c.place_limit_order('BTC-USD', 'buy', 3, 0.01) + + return c + + +@pytest.mark.usefixtures('dc') +@pytest.mark.skip(reason="these test require authentication") +class TestAuthenticatedClient(object): + """Test the authenticated client by validating basic behavior from the + sandbox exchange.""" + def test_get_accounts(self, client): + r = client.get_accounts() + assert type(r) is list + assert 'currency' in r[0] + # Now get a single account + r = client.get_account(account_id=r[0]['id']) + assert type(r) is dict + assert 'currency' in r + + def test_account_history(self, client): + accounts = client.get_accounts() + account_usd = [x for x in accounts if x['currency'] == 'USD'][0]['id'] + r = list(islice(client.get_account_history(account_usd), 5)) + assert type(r) is list + assert 'amount' in r[0] + assert 'details' in r[0] + + # Now exercise the pagination abstraction. Setting limit to 1 means + # each record comes in a separate HTTP response. + history_gen = client.get_account_history(account_usd, limit=1) + r = list(islice(history_gen, 2)) + r2 = list(islice(history_gen, 2)) + assert r != r2 + # Now exercise the `before` parameter. + r3 = list(client.get_account_history(account_usd, before=r2[0]['id'])) + assert r3 == r + + def test_get_account_holds(self, client): + accounts = client.get_accounts() + account_usd = [x for x in accounts if x['currency'] == 'USD'][0]['id'] + r = list(client.get_account_holds(account_usd)) + assert type(r) is list + assert 'type' in r[0] + assert 'ref' in r[0] + + def test_convert_stablecoin(self, client): + r = client.convert_stablecoin('10.0', 'USD', 'USDC') + assert type(r) is dict + assert 'id' in r + assert r['amount'] == '10.00000000' + assert r['from'] == 'USD' + assert r['to'] == 'USDC' + + def test_place_order(self, client): + r = client.place_order('BTC-USD', 'buy', 'limit', + price=0.62, size=0.0144) + assert type(r) is dict + assert r['stp'] == 'dc' + + def test_place_limit_order(self, client): + r = client.place_limit_order('BTC-USD', 'buy', 4.43, 0.01232) + assert type(r) is dict + assert 'executed_value' in r + assert not r['post_only'] + client.cancel_order(r['id']) + + def test_place_market_order(self, client): + r = client.place_market_order('BTC-USD', 'buy', size=0.01) + assert 'status' in r + assert r['type'] == 'market' + client.cancel_order(r['id']) + + # This one probably won't go through + r = client.place_market_order('BTC-USD', 'buy', funds=100000) + assert type(r) is dict + + @pytest.mark.parametrize('stop_type', ['entry', 'loss']) + def test_place_stop_order(self, client, stop_type): + client.cancel_all() + r = client.place_stop_order('BTC-USD', stop_type, 100, 0.01) + assert type(r) is dict + assert r['stop'] == stop_type + assert r['stop_price'] == '100' + assert r['type'] == 'limit' + client.cancel_order(r['id']) + + def test_place_invalid_stop_order(self, client): + client.cancel_all() + with pytest.raises(ValueError): + client.place_stop_order('BTC-USD', 'fake_stop_type', 5.65, 0.01) + + def test_cancel_order(self, client): + r = client.place_limit_order('BTC-USD', 'buy', 4.43, 0.01232) + time.sleep(0.2) + r2 = client.cancel_order(r['id']) + assert r2[0] == r['id'] + + def test_cancel_all(self, client): + r = client.cancel_all() + assert type(r) is list + + def test_get_order(self, client): + r = client.place_limit_order('BTC-USD', 'buy', 4.43, 0.01232) + time.sleep(0.2) + r2 = client.get_order(r['id']) + assert r2['id'] == r['id'] + + def test_get_orders(self, client): + r = list(islice(client.get_orders(), 10)) + assert type(r) is list + assert 'created_at' in r[0] + + def test_get_fills(self, client): + r = list(islice(client.get_orders(), 10)) + assert type(r) is list + assert 'fill_fees' in r[0] + + def test_get_fundings(self, client): + r = list(islice(client.get_fundings(), 10)) + assert type(r) is list + + def test_repay_funding(self, client): + # This request gets denied + r = client.repay_funding(2.1, 'USD') + + def test_get_position(self, client): + r = client.get_position() + assert 'accounts' in r + + def test_get_payment_methods(self, client): + r = client.get_payment_methods() + assert type(r) is list + + def test_get_coinbase_accounts(self, client): + r = client.get_coinbase_accounts() + assert type(r) is list + + def test_get_trailing_volume(self, client): + r = client.get_trailing_volume() + assert type(r) is list + + def test_get_fees(self, client): + r = client.get_fees() + assert type(r) is dict diff --git a/tests/test_public_client.py b/tests/test_public_client.py new file mode 100644 index 00000000..10d2d1f7 --- /dev/null +++ b/tests/test_public_client.py @@ -0,0 +1,76 @@ +import pytest +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 PublicClient() + + +@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 + + @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 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): + r = client.get_product_ticker('BTC-USD') + assert type(r) is dict + assert 'ask' in r + assert 'trade_id' in r + + def test_get_product_trades(self, client): + r = list(islice(client.get_product_trades('BTC-USD'), 200)) + assert type(r) is list + assert 'trade_id' in r[0] + + current_time = datetime.datetime.now() + + @pytest.mark.parametrize('start,end,granularity', + [(current_time - relativedelta(months=1), + current_time, 21600)]) + def test_get_historic_rates(self, client, start, end, granularity): + r = client.get_product_historic_rates('BTC-USD', start=start, end=end, granularity=granularity) + assert type(r) is list + for ticker in r: + assert( all( [type(x) in (int, float) for x in ticker ] ) ) + + def test_get_product_24hr_stats(self, client): + r = client.get_product_24hr_stats('BTC-USD') + 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 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"