diff --git a/coinbaseadvanced/client.py b/coinbaseadvanced/client.py index cb27174..9079cb3 100644 --- a/coinbaseadvanced/client.py +++ b/coinbaseadvanced/client.py @@ -3,22 +3,36 @@ """ from typing import List +from enum import Enum from datetime import datetime, timedelta +from cryptography.hazmat.primitives import serialization +import jwt import hmac import hashlib import time import json import requests +from coinbaseadvanced.models.common import UnixTime from coinbaseadvanced.models.fees import TransactionsSummary -from coinbaseadvanced.models.products import ProductsPage, Product, CandlesPage,\ +from coinbaseadvanced.models.products import BidAsksPage, ProductBook, ProductsPage, Product, CandlesPage,\ TradesPage, ProductType, Granularity, GRANULARITY_MAP_IN_MINUTES from coinbaseadvanced.models.accounts import AccountsPage, Account from coinbaseadvanced.models.orders import OrderPlacementSource, OrdersPage, Order,\ OrderBatchCancellation, FillsPage, Side, StopDirection, OrderType +class AuthSchema(Enum): + """ + Enum representing authetication schema: + https://docs.cloud.coinbase.com/advanced-trade-api/docs/auth#authentication-schemes + """ + + CLOUD_API_TRADING_KEYS = "CLOUD_API_TRADING_KEYS" + LEGACY_API_KEYS = "LEGACY_API_KEYS" + + class CoinbaseAdvancedTradeAPIClient(object): """ API Client for Coinbase Advanced Trade endpoints. @@ -28,11 +42,36 @@ def __init__(self, api_key: str, secret_key: str, base_url: str = 'https://api.coinbase.com', - timeout: int = 10) -> None: + timeout: int = 10, + auth_schema: AuthSchema = AuthSchema.LEGACY_API_KEYS + ) -> None: self._base_url = base_url + self._host = base_url[8:] self._api_key = api_key self._secret_key = secret_key self.timeout = timeout + self._auth_schema = auth_schema + + @staticmethod + def from_legacy_api_keys(api_key: str, + secret_key: str): + """ + Factory method for legacy auth schema. + API keys for this schema are generated via: https://www.coinbase.com/settings/api + """ + return CoinbaseAdvancedTradeAPIClient(api_key=api_key, secret_key=secret_key) + + @staticmethod + def from_cloud_api_keys(api_key_name: str, + private_key: str): + """ + Factory method for cloud auth schema (recommended by Coinbase). + API keys for this schema are generated via: https://cloud.coinbase.com/access/api + """ + return CoinbaseAdvancedTradeAPIClient(api_key=api_key_name, secret_key=private_key, + auth_schema=AuthSchema.CLOUD_API_TRADING_KEYS) + + # Accounts # # Accounts # @@ -57,11 +96,12 @@ def list_accounts(self, limit: int = 49, cursor: str = None) -> AccountsPage: method = "GET" query_params = '?limit='+str(limit) + headers = self._build_request_headers(method, request_path) if self._is_legacy_auth( + ) else self._build_request_headers_for_cloud(method, self._host, request_path) + if cursor is not None: query_params = query_params + '&cursor='+cursor - headers = self._build_request_headers(method, request_path) - response = requests.get(self._base_url+request_path+query_params, headers=headers, timeout=self.timeout) @@ -105,7 +145,8 @@ def get_account(self, account_id: str) -> Account: request_path = f"/api/v3/brokerage/accounts/{account_id}" method = "GET" - headers = self._build_request_headers(method, request_path) + headers = self._build_request_headers(method, request_path) if self._is_legacy_auth( + ) else self._build_request_headers_for_cloud(method, self._host, request_path) response = requests.get(self._base_url+request_path, headers=headers, timeout=self.timeout) @@ -275,7 +316,8 @@ def create_order(self, client_order_id: str, 'order_configuration': order_configuration } - headers = self._build_request_headers(method, request_path, json.dumps(payload)) + headers = self._build_request_headers(method, request_path, json.dumps(payload)) if self._is_legacy_auth( + ) else self._build_request_headers_for_cloud(method, self._host, request_path) response = requests.post(self._base_url+request_path, json=payload, headers=headers, timeout=self.timeout) @@ -300,7 +342,8 @@ def cancel_orders(self, order_ids: list) -> OrderBatchCancellation: 'order_ids': order_ids, } - headers = self._build_request_headers(method, request_path, json.dumps(payload)) + headers = self._build_request_headers(method, request_path, json.dumps(payload)) if self._is_legacy_auth( + ) else self._build_request_headers_for_cloud(method, self._host, request_path) response = requests.post(self._base_url+request_path, json=payload, headers=headers, @@ -398,7 +441,8 @@ def list_orders( if order_placement_source is not None: query_params = self._next_param(query_params) + 'order_placement_source=' + order_placement_source.value - headers = self._build_request_headers(method, request_path) + headers = self._build_request_headers(method, request_path) if self._is_legacy_auth( + ) else self._build_request_headers_for_cloud(method, self._host, request_path) response = requests.get(self._base_url+request_path+query_params, headers=headers, @@ -495,7 +539,8 @@ def list_fills(self, order_id: str = None, product_id: str = None, start_date: d if cursor is not None: query_params = self._next_param(query_params) + 'cursor=' + cursor - headers = self._build_request_headers(method, request_path) + headers = self._build_request_headers(method, request_path) if self._is_legacy_auth( + ) else self._build_request_headers_for_cloud(method, self._host, request_path) response = requests.get(self._base_url+request_path+query_params, headers=headers, @@ -530,7 +575,8 @@ def get_order(self, order_id: str) -> Order: request_path = f"/api/v3/brokerage/orders/historical/{order_id}" method = "GET" - headers = self._build_request_headers(method, request_path) + headers = self._build_request_headers(method, request_path) if self._is_legacy_auth( + ) else self._build_request_headers_for_cloud(method, self._host, request_path) response = requests.get(self._base_url+request_path, headers=headers, timeout=self.timeout) @@ -568,7 +614,8 @@ def list_products(self, if product_type is not None: query_params = self._next_param(query_params) + 'product_type=' + product_type.value - headers = self._build_request_headers(method, request_path) + headers = self._build_request_headers(method, request_path) if self._is_legacy_auth( + ) else self._build_request_headers_for_cloud(method, self._host, request_path) response = requests.get(self._base_url+request_path+query_params, headers=headers, @@ -590,7 +637,8 @@ def get_product(self, product_id: str) -> Product: request_path = f"/api/v3/brokerage/products/{product_id}" method = "GET" - headers = self._build_request_headers(method, request_path) + headers = self._build_request_headers(method, request_path) if self._is_legacy_auth( + ) else self._build_request_headers_for_cloud(method, self._host, request_path) response = requests.get(self._base_url+request_path, headers=headers, timeout=self.timeout) @@ -624,7 +672,8 @@ def get_product_candles( query_params = self._next_param(query_params) + 'end=' + str(int(end_date.timestamp())) query_params = self._next_param(query_params) + 'granularity=' + granularity.value - headers = self._build_request_headers(method, request_path) + headers = self._build_request_headers(method, request_path) if self._is_legacy_auth( + ) else self._build_request_headers_for_cloud(method, self._host, request_path) response = requests.get(self._base_url+request_path+query_params, headers=headers, @@ -697,7 +746,8 @@ def get_market_trades( query_params = self._next_param(query_params) + 'limit=' + str(limit) - headers = self._build_request_headers(method, request_path) + headers = self._build_request_headers(method, request_path) if self._is_legacy_auth( + ) else self._build_request_headers_for_cloud(method, self._host, request_path) response = requests.get(self._base_url+request_path+query_params, headers=headers, @@ -706,6 +756,61 @@ def get_market_trades( trades_page = TradesPage.from_response(response) return trades_page + def get_product_book(self, product_id: str, limit: int = None) -> ProductBook: + """ + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getproductbook + + Get a list of bids/asks for a single product. + The amount of detail shown can be customized with the limit parameter. + + Args: + - product_id: The trading pair. + - limit: A pagination limit. + """ + + request_path = f"/api/v3/brokerage/product_book" + method = "GET" + + query_params = '' + if product_id is not None: + query_params = self._next_param(query_params) + 'product_id='+product_id + + if limit is not None: + query_params = self._next_param(query_params) + 'limit='+str(limit) + + headers = self._build_request_headers(method, request_path) if self._is_legacy_auth( + ) else self._build_request_headers_for_cloud(method, self._host, request_path) + + response = requests.get(self._base_url+request_path+query_params, headers=headers, timeout=self.timeout) + + bid_asks_page = ProductBook.from_response(response) + return bid_asks_page + + def get_best_bid_ask(self, product_ids: List[str] = None) -> BidAsksPage: + """ + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getbestbidask + + Get the best bid/ask for all products. A subset of all products can be returned instead by using the product_ids input. + + Args: + - product_ids: Subset of all products to be returned instead. + """ + + request_path = f"/api/v3/brokerage/best_bid_ask" + method = "GET" + + query_params = '' + if product_ids is not None: + query_params = self._next_param(query_params) + 'product_ids='+'&product_ids='.join(product_ids) + + headers = self._build_request_headers(method, request_path) if self._is_legacy_auth( + ) else self._build_request_headers_for_cloud(method, self._host, request_path) + + response = requests.get(self._base_url+request_path+query_params, headers=headers, timeout=self.timeout) + + bid_asks_page = BidAsksPage.from_response(response) + return bid_asks_page + # Fees # def get_transactions_summary(self, @@ -739,7 +844,8 @@ def get_transactions_summary(self, if product_type is not None: query_params = self._next_param(query_params) + 'product_type='+product_type.value - headers = self._build_request_headers(method, request_path) + headers = self._build_request_headers(method, request_path) if self._is_legacy_auth( + ) else self._build_request_headers_for_cloud(method, self._host, request_path) response = requests.get(self._base_url+request_path+query_params, headers=headers, @@ -748,7 +854,59 @@ def get_transactions_summary(self, page = TransactionsSummary.from_response(response) return page - # Helpers # + # Common # + + def get_unix_time(self) -> UnixTime: + """ + https://docs.cloud.coinbase.com/advanced-trade-api/reference/retailbrokerageapi_getunixtime + + Get the current time from the Coinbase Advanced API. + + """ + + request_path = f"/api/v3/brokerage/time" + method = "GET" + + headers = self._build_request_headers(method, request_path) if self._is_legacy_auth( + ) else self._build_request_headers_for_cloud(method, self._host, request_path) + + response = requests.get(self._base_url+request_path, headers=headers, timeout=self.timeout) + + time = UnixTime.from_response(response) + return time + + # Helpers Methods # + + ## Cloud Auth ## + + def _build_request_headers_for_cloud(self, method, host, request_path): + uri = f"{method} {host}{request_path}" + jwt_token = self._build_jwt("retail_rest_api_proxy", uri) + + return { + "Authorization": f"Bearer {jwt_token}", + } + + def _build_jwt(self, service, uri): + private_key_bytes = self._secret_key.encode('utf-8') + private_key = serialization.load_pem_private_key(private_key_bytes, password=None) + jwt_payload = { + 'sub': self._api_key, + 'iss': "coinbase-cloud", + 'nbf': int(time.time()), + 'exp': int(time.time()) + 60, + 'aud': [service], + 'uri': uri, + } + jwt_token = jwt.encode( + jwt_payload, + private_key, + algorithm='ES256', + headers={'kid': self._api_key, 'nonce': str(int(time.time()))}, + ) + return jwt_token + + ## Legacy Auth ## def _build_request_headers(self, method, request_path, body=''): timestamp = str(int(time.time())) @@ -771,5 +929,10 @@ def _create_signature(self, message): return signature + def _is_legacy_auth(self) -> bool: + return self._auth_schema == AuthSchema.LEGACY_API_KEYS + + ## Others ## + def _next_param(self, query_params: str) -> str: return query_params + ('?' if query_params == '' else '&') diff --git a/coinbaseadvanced/models/accounts.py b/coinbaseadvanced/models/accounts.py index 3d0f843..f0d6d0d 100644 --- a/coinbaseadvanced/models/accounts.py +++ b/coinbaseadvanced/models/accounts.py @@ -2,16 +2,16 @@ Object models for account related endpoints args and response. """ -import json from uuid import UUID from datetime import datetime from typing import List import requests +from coinbaseadvanced.models.common import BaseModel from coinbaseadvanced.models.error import CoinbaseAdvancedTradeAPIError -class AvailableBalance: +class AvailableBalance(BaseModel): """ Available Balance object. """ @@ -26,7 +26,7 @@ def __init__(self, value: str, currency: str, **kwargs) -> None: self.kwargs = kwargs -class Account: +class Account(BaseModel): """ Object representing an account. """ @@ -73,12 +73,12 @@ def from_response(cls, response: requests.Response) -> 'Account': if not response.ok: raise CoinbaseAdvancedTradeAPIError.not_ok_response(response) - result = json.loads(response.text) + result = response.json() account_dict = result['account'] return cls(**account_dict) -class AccountsPage: +class AccountsPage(BaseModel): """ Page of accounts. """ @@ -114,7 +114,7 @@ def from_response(cls, response: requests.Response) -> 'AccountsPage': if not response.ok: raise CoinbaseAdvancedTradeAPIError.not_ok_response(response) - result = json.loads(response.text) + result = response.json() return cls(**result) def __iter__(self): diff --git a/coinbaseadvanced/models/common.py b/coinbaseadvanced/models/common.py new file mode 100644 index 0000000..e361234 --- /dev/null +++ b/coinbaseadvanced/models/common.py @@ -0,0 +1,53 @@ +""" +Object models for order related endpoints args and response. +""" + +from coinbaseadvanced.models.error import CoinbaseAdvancedTradeAPIError + +import requests + + +class BaseModel: + + def __str__(self): + attributes = ", ".join(f"{key}={value}" for key, value in self.__dict__.items()) + return f"{self.__class__.__name__}({attributes})" + + def __repr__(self): + attributes = ", ".join(f"{key}={value!r}" for key, value in self.__dict__.items()) + return f"{self.__class__.__name__}({attributes})" + + +class UnixTime(BaseModel): + """ + Unix time in different formats. + """ + + iso: str + epochSeconds: str + epochMillis: str + + def __init__(self, + iso: str, + epochSeconds: str, + epochMillis: str, + **kwargs + ) -> None: + + self.iso = iso + self.epochSeconds = epochSeconds + self.epochMillis = epochMillis + + self.kwargs = kwargs + + @classmethod + def from_response(cls, response: requests.Response) -> 'UnixTime': + """ + Factory Method. + """ + + if not response.ok: + raise CoinbaseAdvancedTradeAPIError.not_ok_response(response) + + result = response.json() + return cls(**result) diff --git a/coinbaseadvanced/models/fees.py b/coinbaseadvanced/models/fees.py index 132870a..a43d623 100644 --- a/coinbaseadvanced/models/fees.py +++ b/coinbaseadvanced/models/fees.py @@ -2,13 +2,13 @@ Object models for fees related endpoints args and response. """ -import json import requests +from coinbaseadvanced.models.common import BaseModel from coinbaseadvanced.models.error import CoinbaseAdvancedTradeAPIError -class FeeTier: +class FeeTier(BaseModel): """ Fee Tier object. """ @@ -34,7 +34,7 @@ def __init__(self, self.kwargs = kwargs -class GoodsAndServicesTax: +class GoodsAndServicesTax(BaseModel): """ Object representing Goods and Services Tax data. """ @@ -49,7 +49,7 @@ def __init__(self, rate: str, type: str, **kwargs) -> None: self.kwargs = kwargs -class MarginRate: +class MarginRate(BaseModel): """ Margin Rate. """ @@ -62,7 +62,7 @@ def __init__(self, value: str, **kwargs) -> None: self.kwargs = kwargs -class TransactionsSummary: +class TransactionsSummary(BaseModel): """ Transactions Summary. """ @@ -76,10 +76,12 @@ class TransactionsSummary: advanced_trade_only_fees: int coinbase_pro_volume: int coinbase_pro_fees: int + total_balance: str + has_promo_fee: bool def __init__(self, total_volume: int, total_fees: int, fee_tier: dict, margin_rate: dict, goods_and_services_tax: dict, advanced_trade_only_volume: int, advanced_trade_only_fees: int, - coinbase_pro_volume: int, coinbase_pro_fees: int, **kwargs) -> None: + coinbase_pro_volume: int, coinbase_pro_fees: int, total_balance: str, has_promo_fee: bool, **kwargs) -> None: self.total_volume = total_volume self.total_fees = total_fees self.fee_tier = FeeTier(**fee_tier) if fee_tier is not None else None @@ -91,6 +93,9 @@ def __init__(self, total_volume: int, total_fees: int, fee_tier: dict, margin_ra self.coinbase_pro_volume = coinbase_pro_volume self.coinbase_pro_fees = coinbase_pro_fees + self.total_balance = total_balance + self.has_promo_fee = has_promo_fee + self.kwargs = kwargs @classmethod @@ -102,5 +107,5 @@ def from_response(cls, response: requests.Response) -> 'TransactionsSummary': if not response.ok: raise CoinbaseAdvancedTradeAPIError.not_ok_response(response) - result = json.loads(response.text) + result = response.json() return cls(**result) diff --git a/coinbaseadvanced/models/orders.py b/coinbaseadvanced/models/orders.py index 42fbd7a..41d6e07 100644 --- a/coinbaseadvanced/models/orders.py +++ b/coinbaseadvanced/models/orders.py @@ -5,10 +5,9 @@ from typing import List from datetime import datetime from enum import Enum - +from coinbaseadvanced.models.common import BaseModel from coinbaseadvanced.models.error import CoinbaseAdvancedTradeAPIError -import json import requests @@ -51,7 +50,7 @@ class OrderPlacementSource(Enum): RETAIL_ADVANCDED = "RETAIL_ADVANCED" -class OrderError: +class OrderError(BaseModel): """ Class encapsulating order error fields. """ @@ -73,7 +72,7 @@ def __init__(self, error: str = '', message: str = '', error_details: str = '', self.kwargs = kwargs -class LimitGTC: +class LimitGTC(BaseModel): """ Limit till cancelled order configuration. """ @@ -90,7 +89,7 @@ def __init__(self, base_size: str, limit_price: str, post_only: bool, **kwargs) self.kwargs = kwargs -class LimitGTD: +class LimitGTD(BaseModel): """ Limit till date order configuration. """ @@ -110,7 +109,7 @@ def __init__(self, base_size: str, limit_price: str, post_only: bool, end_time: self.kwargs = kwargs -class MarketIOC: +class MarketIOC(BaseModel): """ Market order configuration. """ @@ -125,7 +124,7 @@ def __init__(self, quote_size: str = None, base_size: str = None, **kwargs) -> N self.kwargs = kwargs -class StopLimitGTC: +class StopLimitGTC(BaseModel): """ Stop-Limit till cancelled order configuration. """ @@ -148,7 +147,7 @@ def __init__(self, self.kwargs = kwargs -class StopLimitGTD: +class StopLimitGTD(BaseModel): """ Stop-Limit till date order configuration. """ @@ -175,7 +174,24 @@ def __init__(self, self.kwargs = kwargs -class OrderConfiguration: +class OrderEditRecord(BaseModel): + """ + Stop-Limit till date order configuration. + """ + + def __init__(self, + price: str, + size: str, + replace_accept_timestamp: str, + **kwargs) -> None: + self.price = price + self.size = size + self.replace_accept_timestamp = replace_accept_timestamp + + self.kwargs = kwargs + + +class OrderConfiguration(BaseModel): """ Order Configuration. One of four possible fields should only be settled. """ @@ -203,7 +219,7 @@ def __init__(self, market_market_ioc: dict = None, limit_limit_gtc: dict = None, self.kwargs = kwargs -class Order: +class Order(BaseModel): """ Class reprensenting an order. This support the `create_order*` endpoints and the `get_order` endpoint. @@ -242,11 +258,16 @@ class Order: order_placement_source: str outstanding_hold_amount: str + is_liquidation: bool + last_fill_time: str + edit_history: List[OrderEditRecord] + leverage: str + margin_type: str + order_error: OrderError def __init__(self, order_id: str, product_id: str, side: str, client_order_id: str, order_configuration: dict, - user_id: str = None, status: str = None, time_in_force: str = None, @@ -272,6 +293,12 @@ def __init__(self, order_id: str, product_id: str, side: str, client_order_id: s order_placement_source: str = None, outstanding_hold_amount: str = None, + is_liquidation: bool = None, + last_fill_time: str = None, + edit_history: List[OrderEditRecord] = None, + leverage: str = None, + margin_type: str = None, + order_error: dict = None, **kwargs) -> None: self.order_id = order_id self.product_id = product_id @@ -307,6 +334,13 @@ def __init__(self, order_id: str, product_id: str, side: str, client_order_id: s self.order_placement_source = order_placement_source self.outstanding_hold_amount = outstanding_hold_amount + self.is_liquidation = is_liquidation + self.last_fill_time = last_fill_time + self.edit_history = [OrderEditRecord( + **edit) for edit in edit_history] if edit_history is not None else None, + self.leverage = leverage + self.margin_type = margin_type + self.order_error = OrderError(**order_error) if order_error is not None else None self.kwargs = kwargs @@ -320,15 +354,15 @@ def from_create_order_response(cls, response: requests.Response) -> 'Order': if not response.ok: raise CoinbaseAdvancedTradeAPIError.not_ok_response(response) - result = json.loads(response.text) + result = response.json() if not result['success']: error_response = result['error_response'] return cls( None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, - None, None, None, None, None, None, None, - error_response) + None, None, None, None, None, None, None, None, None, + None, None, None, None, None, None, None, error_response) success_response = result['success_response'] order_configuration = result['order_configuration'] @@ -343,14 +377,14 @@ def from_get_order_response(cls, response: requests.Response) -> 'Order': if not response.ok: raise CoinbaseAdvancedTradeAPIError.not_ok_response(response) - result = json.loads(response.text) + result = response.json() order = result['order'] return cls(**order) -class OrdersPage: +class OrdersPage(BaseModel): """ Orders page. """ @@ -384,14 +418,14 @@ def from_response(cls, response: requests.Response) -> 'OrdersPage': if not response.ok: raise CoinbaseAdvancedTradeAPIError.not_ok_response(response) - result = json.loads(response.text) + result = response.json() return cls(**result) def __iter__(self): return self.orders.__iter__() -class OrderCancellation: +class OrderCancellation(BaseModel): """ Order cancellation. """ @@ -408,7 +442,7 @@ def __init__(self, success: bool, failure_reason: str, order_id: str, **kwargs) self.kwargs = kwargs -class OrderBatchCancellation: +class OrderBatchCancellation(BaseModel): """ Batch/Page of order cancellations. """ @@ -429,12 +463,12 @@ def from_response(cls, response: requests.Response) -> 'Order': if not response.ok: raise CoinbaseAdvancedTradeAPIError.not_ok_response(response) - result = json.loads(response.text) + result = response.json() return cls(**result) -class Fill: +class Fill(BaseModel): """ Object representing an order filled. """ @@ -492,7 +526,7 @@ def __init__( self.kwargs = kwargs -class FillsPage: +class FillsPage(BaseModel): """ Page of orders filled. """ @@ -520,7 +554,7 @@ def from_response(cls, response: requests.Response) -> 'FillsPage': if not response.ok: raise CoinbaseAdvancedTradeAPIError.not_ok_response(response) - result = json.loads(response.text) + result = response.json() return cls(**result) def __iter__(self): diff --git a/coinbaseadvanced/models/products.py b/coinbaseadvanced/models/products.py index 399dd00..90e5de9 100644 --- a/coinbaseadvanced/models/products.py +++ b/coinbaseadvanced/models/products.py @@ -6,10 +6,9 @@ from datetime import datetime from typing import List from enum import Enum - +from coinbaseadvanced.models.common import BaseModel from coinbaseadvanced.models.error import CoinbaseAdvancedTradeAPIError -import json import requests @@ -49,7 +48,7 @@ class Granularity(Enum): ONE_DAY = "ONE_DAY" -class Product: +class Product(BaseModel): """ Object representing a product. """ @@ -162,12 +161,12 @@ def from_response(cls, response: requests.Response) -> 'Product': if not response.ok: raise CoinbaseAdvancedTradeAPIError.not_ok_response(response) - result = json.loads(response.text) + result = response.json() product_dict = result return cls(**product_dict) -class ProductsPage: +class ProductsPage(BaseModel): """ Products Page. """ @@ -192,26 +191,26 @@ def from_response(cls, response: requests.Response) -> 'ProductsPage': if not response.ok: raise CoinbaseAdvancedTradeAPIError.not_ok_response(response) - result = json.loads(response.text) + result = response.json() return cls(**result) def __iter__(self): return self.products.__iter__() -class Candle: +class Candle(BaseModel): """ Candle object. """ - start: int + start: str low: str high: str open: str close: str volume: int - def __init__(self, start: int, low: str, high: str, open: str, close: str, volume: int, **kwargs) -> None: + def __init__(self, start: str, low: str, high: str, open: str, close: str, volume: int, **kwargs) -> None: self.start = start self.low = low self.high = high @@ -222,7 +221,7 @@ def __init__(self, start: int, low: str, high: str, open: str, close: str, volum self.kwargs = kwargs -class CandlesPage: +class CandlesPage(BaseModel): """ Page of product candles. """ @@ -243,14 +242,108 @@ def from_response(cls, response: requests.Response) -> 'CandlesPage': if not response.ok: raise CoinbaseAdvancedTradeAPIError.not_ok_response(response) - result = json.loads(response.text) + result = response.json() return cls(**result) def __iter__(self): return self.candles.__iter__() -class Trade: +class Bid(BaseModel): + price: str + size: str + + def __init__(self, price: str, size: str, **kwargs): + self.price = price + self.size = size + + self.kwargs = kwargs + + +class Ask(BaseModel): + price: str + size: str + + def __init__(self, price: str, size: str, **kwargs): + self.price = price + self.size = size + + self.kwargs = kwargs + + +class BidAsk(BaseModel): + """ + BidAsk object. + """ + + product_id: str + bids: List[Bid] + asks: List[Ask] + time: str + + def __init__(self, product_id: str, bids: List[Bid], asks: List[Ask], time: str, **kwargs) -> None: + self.product_id = product_id + self.bids = list(map(lambda x: Bid(**x), bids)) if bids is not None else None + self.asks = list(map(lambda x: Ask(**x), asks)) if asks is not None else None + self.time = time + + self.kwargs = kwargs + + +class BidAsksPage(BaseModel): + """ + Page of bid/asks for products. + """ + + pricebooks: List + + def __init__(self, pricebooks: List[BidAsk], **kwargs) -> None: + self.pricebooks = list(map(lambda x: BidAsk(**x), pricebooks)) if pricebooks is not None else None + + self.kwargs = kwargs + + @classmethod + def from_response(cls, response: requests.Response) -> 'BidAsksPage': + """ + Factory Method. + """ + + if not response.ok: + raise CoinbaseAdvancedTradeAPIError.not_ok_response(response) + + result = response.json() + return cls(**result) + + def __iter__(self): + return self.pricebooks.__iter__() + + +class ProductBook(BaseModel): + """ + Product bid/asks. + """ + + pricebook: BidAsk + + def __init__(self, pricebook: dict, **kwargs) -> None: + self.pricebook = BidAsk(**pricebook) + + self.kwargs = kwargs + + @classmethod + def from_response(cls, response: requests.Response) -> 'ProductBook': + """ + Factory Method. + """ + + if not response.ok: + raise CoinbaseAdvancedTradeAPIError.not_ok_response(response) + + result = response.json() + return cls(**result) + + +class Trade(BaseModel): """ Trade object data. """ @@ -285,7 +378,7 @@ def __init__(self, self.kwargs = kwargs -class TradesPage: +class TradesPage(BaseModel): """ Page of trades. """ @@ -314,7 +407,7 @@ def from_response(cls, response: requests.Response) -> 'TradesPage': if not response.ok: raise CoinbaseAdvancedTradeAPIError.not_ok_response(response) - result = json.loads(response.text) + result = response.json() return cls(**result) def __iter__(self): diff --git a/tests/fixtures/create_order_failure_no_funds_response.json b/tests/fixtures/create_order_failure_no_funds_response.json new file mode 100644 index 0000000..fceab1f --- /dev/null +++ b/tests/fixtures/create_order_failure_no_funds_response.json @@ -0,0 +1,18 @@ +{ + "success": false, + "failure_reason": "UNKNOWN_FAILURE_REASON", + "order_id": "", + "error_response": { + "error": "INSUFFICIENT_FUND", + "message": "Insufficient balance in source account", + "error_details": "", + "preview_failure_reason": "PREVIEW_INSUFFICIENT_FUND" + }, + "order_configuration": { + "limit_limit_gtc": { + "base_size": "1000", + "limit_price": ".15", + "post_only": false + } + } +} \ No newline at end of file diff --git a/tests/fixtures/fixtures.py b/tests/fixtures/fixtures.py index f5ff097..d282923 100644 --- a/tests/fixtures/fixtures.py +++ b/tests/fixtures/fixtures.py @@ -1,9 +1,11 @@ from unittest import mock +import json -def fixtured_mock_response( + +def _fixtured_mock_response( ok: bool, - text: str): + text: str) -> mock.Mock: """ since we typically test a bunch of different requests calls for a service, we are going to do @@ -14,221 +16,254 @@ def fixtured_mock_response( # set status code and content mock_resp.ok = ok mock_resp.text = text + mock_resp.json.return_value = json.loads(text) return mock_resp -def fixture_get_account_success_response() -> str: +def fixture_get_account_success_response() -> mock.Mock: with open('tests/fixtures/get_account_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_default_failure_response() -> str: +def fixture_default_failure_response() -> mock.Mock: with open('tests/fixtures/default_failure_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=False, text=content) -def fixture_list_accounts_success_response() -> str: +def fixture_list_accounts_success_response() -> mock.Mock: with open('tests/fixtures/list_accounts_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_list_accounts_all_call_1_success_response() -> str: +def fixture_list_accounts_all_call_1_success_response() -> mock.Mock: with open('tests/fixtures/list_accounts_all_call_1_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_list_accounts_all_call_2_success_response() -> str: +def fixture_list_accounts_all_call_2_success_response() -> mock.Mock: with open('tests/fixtures/list_accounts_all_call_2_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_create_limit_order_success_response() -> str: +def fixture_create_limit_order_success_response() -> mock.Mock: with open('tests/fixtures/create_limit_order_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_create_stop_limit_order_success_response() -> str: +def fixture_create_stop_limit_order_success_response() -> mock.Mock: with open('tests/fixtures/create_stop_limit_order_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_create_buy_market_order_success_response() -> str: +def fixture_create_buy_market_order_success_response() -> mock.Mock: with open('tests/fixtures/create_buy_market_order_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_create_sell_market_order_success_response() -> str: +def fixture_create_sell_market_order_success_response() -> mock.Mock: with open('tests/fixtures/create_sell_market_order_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_default_order_failure_response() -> str: +def fixture_default_order_failure_response() -> mock.Mock: with open('tests/fixtures/default_order_failure_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=False, text=content) -def fixture_cancel_orders_success_response() -> str: +def fixture_order_failure_no_funds_response() -> mock.Mock: + with open('tests/fixtures/create_order_failure_no_funds_response.json', 'r', encoding="utf-8") as file: + content = file.read() + return _fixtured_mock_response( + ok=True, + text=content) + + +def fixture_cancel_orders_success_response() -> mock.Mock: with open('tests/fixtures/cancel_orders_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_list_orders_success_response() -> str: +def fixture_list_orders_success_response() -> mock.Mock: with open('tests/fixtures/list_orders_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_list_orders_all_call_1_success_response() -> str: +def fixture_list_orders_all_call_1_success_response() -> mock.Mock: with open('tests/fixtures/list_orders_all_call_1_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_list_orders_all_call_2_success_response() -> str: +def fixture_list_orders_all_call_2_success_response() -> mock.Mock: with open('tests/fixtures/list_orders_all_call_2_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_list_orders_with_extra_unnamed_success_response() -> str: +def fixture_list_orders_with_extra_unnamed_success_response() -> mock.Mock: with open('tests/fixtures/list_orders_with_extra_unnamed_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_list_fills_success_response() -> str: +def fixture_list_fills_success_response() -> mock.Mock: with open('tests/fixtures/list_fills_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_list_fills_all_call_1_success_response() -> str: +def fixture_list_fills_all_call_1_success_response() -> mock.Mock: with open('tests/fixtures/list_fills_all_call_1_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_list_fills_all_call_2_success_response() -> str: +def fixture_list_fills_all_call_2_success_response() -> mock.Mock: with open('tests/fixtures/list_fills_all_call_2_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_get_order_success_response() -> str: +def fixture_get_order_success_response() -> mock.Mock: with open('tests/fixtures/get_order_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_list_products_success_response() -> str: +def fixture_list_products_success_response() -> mock.Mock: with open('tests/fixtures/list_products_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_get_product_success_response() -> str: +def fixture_get_product_success_response() -> mock.Mock: with open('tests/fixtures/get_product_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_get_product_candles_success_response() -> str: +def fixture_get_product_candles_success_response() -> mock.Mock: with open('tests/fixtures/get_product_candles_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_get_product_candles_all_call_1_success_response() -> str: +def fixture_get_product_candles_all_call_1_success_response() -> mock.Mock: with open('tests/fixtures/get_product_candles_all_call_1_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_get_product_candles_all_call_2_success_response() -> str: +def fixture_get_product_candles_all_call_2_success_response() -> mock.Mock: with open('tests/fixtures/get_product_candles_all_call_2_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_get_product_candles_all_call_3_success_response() -> str: +def fixture_get_product_candles_all_call_3_success_response() -> mock.Mock: with open('tests/fixtures/get_product_candles_all_call_3_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( + ok=True, + text=content) + + +def fixture_get_best_bid_asks_success_response() -> mock.Mock: + with open('tests/fixtures/get_best_bid_ask_success_response.json', 'r', encoding="utf-8") as file: + content = file.read() + return _fixtured_mock_response( ok=True, text=content) -def fixture_get_trades_success_response() -> str: +def fixture_product_book_success_response() -> mock.Mock: + with open('tests/fixtures/get_product_book_success_response.json', 'r', encoding="utf-8") as file: + content = file.read() + return _fixtured_mock_response( + ok=True, + text=content) + + +def fixture_get_trades_success_response() -> mock.Mock: with open('tests/fixtures/get_trades_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( ok=True, text=content) -def fixture_get_transactions_summary_success_response() -> str: +def fixture_get_transactions_summary_success_response() -> mock.Mock: with open('tests/fixtures/get_transactions_summary_success_response.json', 'r', encoding="utf-8") as file: content = file.read() - return fixtured_mock_response( + return _fixtured_mock_response( + ok=True, + text=content) + + +def fixture_get_unix_time_success_response() -> mock.Mock: + with open('tests/fixtures/get_unix_time_success_response.json', 'r', encoding="utf-8") as file: + content = file.read() + return _fixtured_mock_response( ok=True, text=content) diff --git a/tests/fixtures/get_best_bid_ask_success_response.json b/tests/fixtures/get_best_bid_ask_success_response.json new file mode 100644 index 0000000..5bce086 --- /dev/null +++ b/tests/fixtures/get_best_bid_ask_success_response.json @@ -0,0 +1,36 @@ +{ + "pricebooks": [ + { + "product_id": "BTC-USD", + "bids": [ + { + "price": "41931.59", + "size": "0.11924182" + } + ], + "asks": [ + { + "price": "41933.63", + "size": "0.195" + } + ], + "time": "2023-12-30T03:44:12.783299Z" + }, + { + "product_id": "ETH-USD", + "bids": [ + { + "price": "2295.05", + "size": "0.26142822" + } + ], + "asks": [ + { + "price": "2295.13", + "size": "0.1089284" + } + ], + "time": "2023-12-30T03:44:12.939329Z" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/get_order_success_response.json b/tests/fixtures/get_order_success_response.json index 9d1279c..4cfeed7 100644 --- a/tests/fixtures/get_order_success_response.json +++ b/tests/fixtures/get_order_success_response.json @@ -1,20 +1,20 @@ { "order": { - "order_id": "5fffa9e8-73db-4a2c-8b3f-08509203ac04", + "order_id": "nkj23kj234234", "product_id": "ALGO-USD", - "user_id": "cd5d3286-b5ad-5f48-b356-1fd3e4e73f72", + "user_id": "mknl23nlk234lk234", "order_configuration": { "limit_limit_gtc": { "base_size": "5", - "limit_price": "0.1", + "limit_price": "0.15", "post_only": false } }, "side": "BUY", - "client_order_id": "jbkjbdskfbg73ibukl", + "client_order_id": "WoKEnVudA77LvxR4Uknt", "status": "CANCELLED", "time_in_force": "GOOD_UNTIL_CANCELLED", - "created_time": "2023-01-21T04:21:10.511448Z", + "created_time": "2023-12-29T02:42:39.309158Z", "completion_percentage": "0", "filled_size": "0", "average_filled_price": "0", @@ -32,6 +32,13 @@ "settled": false, "product_type": "SPOT", "reject_message": "", - "cancel_message": "User requested cancel" + "cancel_message": "User requested cancel", + "order_placement_source": "RETAIL_ADVANCED", + "outstanding_hold_amount": "0", + "is_liquidation": false, + "last_fill_time": null, + "edit_history": [], + "leverage": "", + "margin_type": "UNKNOWN_MARGIN_TYPE" } } \ No newline at end of file diff --git a/tests/fixtures/get_product_book_success_response.json b/tests/fixtures/get_product_book_success_response.json new file mode 100644 index 0000000..16d91fb --- /dev/null +++ b/tests/fixtures/get_product_book_success_response.json @@ -0,0 +1,50 @@ +{ + "pricebook": { + "product_id": "BTC-USD", + "bids": [ + { + "price": "43913.56", + "size": "0.04191551" + }, + { + "price": "43911", + "size": "0.2" + }, + { + "price": "43910.67", + "size": "0.06945405" + }, + { + "price": "43910.66", + "size": "0.29841949" + }, + { + "price": "43910.63", + "size": "0.1138502" + } + ], + "asks": [ + { + "price": "43913.57", + "size": "0.50554" + }, + { + "price": "43916.84", + "size": "0.00218754" + }, + { + "price": "43916.86", + "size": "0.06945446" + }, + { + "price": "43916.87", + "size": "0.17" + }, + { + "price": "43917.1", + "size": "0.24125221" + } + ], + "time": "2024-01-06T19:35:04.617630Z" + } +} \ No newline at end of file diff --git a/tests/fixtures/get_transactions_summary_success_response.json b/tests/fixtures/get_transactions_summary_success_response.json index a0ea063..610cb29 100644 --- a/tests/fixtures/get_transactions_summary_success_response.json +++ b/tests/fixtures/get_transactions_summary_success_response.json @@ -13,5 +13,7 @@ "advanced_trade_only_volume": 16.711822509765625, "advanced_trade_only_fees": 0.09837093204259872, "coinbase_pro_volume": 0, - "coinbase_pro_fees": 0 + "coinbase_pro_fees": 0, + "total_balance": "", + "has_promo_fee": false } \ No newline at end of file diff --git a/tests/fixtures/get_unix_time_success_response.json b/tests/fixtures/get_unix_time_success_response.json new file mode 100644 index 0000000..4f0ab42 --- /dev/null +++ b/tests/fixtures/get_unix_time_success_response.json @@ -0,0 +1,5 @@ +{ + "iso": "2023-11-28T00:00:22Z", + "epochSeconds": "1701129622", + "epochMillis": "1701129622115" +} \ No newline at end of file diff --git a/tests/playground.py b/tests/playground.py new file mode 100644 index 0000000..3fd28ba --- /dev/null +++ b/tests/playground.py @@ -0,0 +1,143 @@ +import json +import random +import string + +from dotenv import load_dotenv +import os + +from datetime import datetime, timezone + +from coinbaseadvanced.client import CoinbaseAdvancedTradeAPIClient, Side, StopDirection, Granularity + +from tests.fixtures.fixtures import * + +load_dotenv() # Load environment variables from a .env file + +# Cloud API Trading Keys (NEW/Recommended): https://cloud.coinbase.com/access/api +API_KEY_NAME = os.getenv('API_KEY_NAME') +PRIVATE_KEY = os.getenv('PRIVATE_KEY').replace('\\n', '\n') + +# Legacy API Keys: https://www.coinbase.com/settings/api +API_KEY = os.getenv('API_KEY') +SECRET_KEY = os.getenv('SECRET_KEY') + +# A real Account Id (ALGO WALLET ID, BTC WALLET ID, or ETH WALLET ID, etc...) +ACCOUNT_ID = os.getenv('ACCOUNT_ID') + + +def generate_random_id(): + return ''.join(random.choice(string.ascii_letters + string.digits) for i in range(20)) + + +def audit(func, args, fixture_obj): + print(f"endpoint: {func.__name__}") + result_obj = func() if args is None else func(**args) + + # Checking if Coinbase is returning more items that expected. + if result_obj.kwargs: + print(f" - Response => Object: NEED UPDATE") + + # Checking fixtures files are updated. + result_obj_atttributes = [ + attr for attr, v in result_obj.__dict__.items() + if v is not None and not attr.startswith('__') and not callable(attr) and attr != 'kwargs'] + for k in result_obj_atttributes: + if not k in fixture_obj: + print( + f" - Response => Fixtures: NEED UPDATE, key '{k}' present in live response but not found in fixture.") + + +# Creating the client per authentication methods. +# client = CoinbaseAdvancedTradeAPIClient.from_legacy_api_keys(API_KEY, SECRET_KEY) +client = CoinbaseAdvancedTradeAPIClient.from_cloud_api_keys(API_KEY_NAME, PRIVATE_KEY) +print() + +# Accounts + +# audit(client.list_accounts, None, json.loads(fixture_list_accounts_success_response().text)) +# audit(client.list_accounts_all, None, json.loads(fixture_list_accounts_all_call_1_success_response().text)) +# audit(client.get_account, {'account_id': ACCOUNT_ID}, +# json.loads(fixture_get_account_success_response().text)['account']) + +# Orders + +# Check and Verify here: https://www.coinbase.com/orders) +# when running this section. + +# Unccoment below for runnig the post function which are usually commented + +# fixture_obj = json.loads(fixture_create_limit_order_success_response().text) +# fixture_obj['success_response']['order_configuration'] = fixture_obj['order_configuration'] +# fixture_obj = fixture_obj['success_response'] +# audit(client.create_limit_order, { +# 'client_order_id': generate_random_id(), +# 'product_id': "ALGO-USD", +# 'side': Side.BUY, +# 'limit_price': ".15", +# 'base_size': 1000 +# }, +# fixture_obj +# ) + +# audit(client.cancel_orders, {'order_ids': ["42a266d3-591b-43d4-a968-a9a126f7b1a5", "82c6919f-6884-4127-95af-11db89b21ed3", +# "c1d5ab66-d99a-4329-9c1d-be6a9f32c686"]}, json.loads(fixture_cancel_orders_success_response().text)) + +# audit(client.list_orders, {'start_date': datetime(2023, 1, 25), +# 'end_date': datetime(2023, 1, 30), +# 'limit': 10}, json.loads(fixture_list_orders_success_response().text)) + +# audit(client.list_orders_all, {'start_date': datetime(2023, 1, 25), +# 'end_date': datetime(2023, 1, 30), +# 'limit': 10}, json.loads(fixture_list_orders_all_call_1_success_response().text)) + +# audit(client.list_fills, {'start_date': datetime(2023, 1, 25), +# 'end_date': datetime(2023, 1, 30), +# 'limit': 10}, json.loads(fixture_list_fills_success_response().text)) + +# audit(client.list_fills_all, {'start_date': datetime(2023, 1, 25), +# 'end_date': datetime(2023, 1, 30), +# 'limit': 10}, json.loads(fixture_list_fills_all_call_1_success_response().text)) + +# audit(client.get_order, {'order_id': 'c1d5ab66-d99a-4329-9c1d-be6a9f32c686'}, +# json.loads(fixture_get_order_success_response().text)['order']) + +# Products + +# audit(client.list_products, {'limit': 5}, +# json.loads(fixture_list_products_success_response().text)) + +# audit(client.get_product_candles, { +# 'product_id': "ALGO-USD", +# 'start_date': datetime(2023, 1, 1, tzinfo=timezone.utc), +# 'end_date': datetime(2023, 1, 31, tzinfo=timezone.utc), +# 'granularity': Granularity.ONE_DAY}, +# json.loads(fixture_get_product_candles_success_response().text)) + +# audit(client.get_product_candles_all, { +# 'product_id': "ALGO-USD", +# 'start_date': datetime(2023, 1, 1, tzinfo=timezone.utc), +# 'end_date': datetime(2023, 1, 31, tzinfo=timezone.utc), +# 'granularity': Granularity.ONE_DAY}, +# json.loads(fixture_get_product_candles_all_call_1_success_response().text)) + +# audit(client.get_market_trades, { +# 'product_id': "ALGO-USD", +# 'limit': 10}, +# json.loads(fixture_get_trades_success_response().text)) + +# audit(client.get_best_bid_ask, {"product_ids": ["BTC-USD", "ETH-USD"]}, +# json.loads(fixture_get_best_bid_asks_success_response().text)) + +# audit(client.get_product_book, {"product_id": "BTC-USD", "limit": 5}, +# json.loads(fixture_product_book_success_response().text)) + +# Fees + +# audit(client.get_transactions_summary, { +# 'start_date': datetime(2023, 1, 1, tzinfo=timezone.utc), +# 'end_date': datetime(2023, 1, 31, tzinfo=timezone.utc)}, +# json.loads(fixture_get_transactions_summary_success_response().text)) + +# Common +# audit(client.get_unix_time, {}, +# json.loads(fixture_get_unix_time_success_response().text)) diff --git a/tests/test_client.py b/tests/test_client.py index 1a44eed..e423c63 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,34 +8,7 @@ from coinbaseadvanced.client import CoinbaseAdvancedTradeAPIClient, Side, StopDirection, Granularity from coinbaseadvanced.models.error import CoinbaseAdvancedTradeAPIError -from tests.fixtures.fixtures import \ - fixture_default_failure_response, \ - fixture_get_account_success_response,\ - fixture_list_accounts_success_response,\ - fixture_list_accounts_all_call_1_success_response, \ - fixture_list_accounts_all_call_2_success_response, \ - fixture_create_limit_order_success_response, \ - fixture_create_stop_limit_order_success_response, \ - fixture_create_buy_market_order_success_response, \ - fixture_create_sell_market_order_success_response,\ - fixture_default_order_failure_response, \ - fixture_cancel_orders_success_response,\ - fixture_list_orders_success_response,\ - fixture_list_orders_with_extra_unnamed_success_response,\ - fixture_list_orders_all_call_1_success_response,\ - fixture_list_orders_all_call_2_success_response,\ - fixture_list_fills_success_response,\ - fixture_list_fills_all_call_1_success_response,\ - fixture_list_fills_all_call_2_success_response,\ - fixture_get_order_success_response, \ - fixture_list_products_success_response,\ - fixture_get_product_success_response, \ - fixture_get_product_candles_success_response, \ - fixture_get_product_candles_all_call_1_success_response,\ - fixture_get_product_candles_all_call_2_success_response,\ - fixture_get_product_candles_all_call_3_success_response,\ - fixture_get_trades_success_response, \ - fixture_get_transactions_summary_success_response +from tests.fixtures.fixtures import * class TestCoinbaseAdvancedTradeAPIClient(unittest.TestCase): @@ -431,7 +404,6 @@ def test_create_order_failure(self, mock_post): api_key='kjsldfk32234', secret_key='jlsjljsfd89y98y98shdfjksfd') # Check output - try: client.create_limit_order("nlksdbnfgjd8y9mn,m234", "ALGO-USD", @@ -458,6 +430,24 @@ def test_create_order_failure(self, mock_post): } }) + @mock.patch("coinbaseadvanced.client.requests.post") + def test_create_order_failure_no_funds(self, mock_post): + + mock_resp = fixture_order_failure_no_funds_response() + mock_post.return_value = mock_resp + + client = CoinbaseAdvancedTradeAPIClient( + api_key='kjsldfk32234', secret_key='jlsjljsfd89y98y98shdfjksfd') + + # Check output + order = client.create_limit_order("nlksdbnfgjd8y9mn,m234", + "ALGO-USD", + Side.BUY, + ".19", + 10000) + + self.assertIsNotNone(order.order_error) + @mock.patch("coinbaseadvanced.client.requests.post") def test_cancel_orders_success(self, mock_post): @@ -912,6 +902,78 @@ def test_get_product_candles_all(self, mock_get): self.assertIsNotNone(candle.close) self.assertIsNotNone(candle.volume) + @mock.patch("coinbaseadvanced.client.requests.get") + def test_get_best_bid_asks(self, mock_get): + + mock_resp = fixture_get_best_bid_asks_success_response() + mock_get.return_value = mock_resp + + client = CoinbaseAdvancedTradeAPIClient( + api_key='kjsldfk32234', secret_key='jlsjljsfd89y98y98shdfjksfd') + + bid_asks_page = client.get_best_bid_ask(product_ids=["BTC-USD", "ETH-USD"]) + + # Check input + + call_args = mock_get.call_args_list + + for call in call_args: + args, kwargs = call + self.assertIn( + 'https://api.coinbase.com/api/v3/brokerage/best_bid_ask?product_ids=BTC-USD&product_ids=ETH-USD', + args) + + headers = kwargs['headers'] + self.assertIn('accept', headers) + self.assertIn('CB-ACCESS-KEY', headers) + self.assertIn('CB-ACCESS-TIMESTAMP', headers) + self.assertIn('CB-ACCESS-SIGN', headers) + + # Check output + + self.assertIsNotNone(bid_asks_page) + + pricebooks = bid_asks_page.pricebooks + self.assertEqual(len(pricebooks), 2) + + for bidask in pricebooks: + self.assertIsNotNone(bidask) + + @mock.patch("coinbaseadvanced.client.requests.get") + def test_product_book(self, mock_get): + + mock_resp = fixture_product_book_success_response() + mock_get.return_value = mock_resp + + client = CoinbaseAdvancedTradeAPIClient( + api_key='kjsldfk32234', secret_key='jlsjljsfd89y98y98shdfjksfd') + + product_book = client.get_product_book(product_id="BTC-USD", limit=5) + + # Check input + + call_args = mock_get.call_args_list + + for call in call_args: + args, kwargs = call + self.assertIn( + 'https://api.coinbase.com/api/v3/brokerage/product_book?product_id=BTC-USD&limit=5', + args) + + headers = kwargs['headers'] + self.assertIn('accept', headers) + self.assertIn('CB-ACCESS-KEY', headers) + self.assertIn('CB-ACCESS-TIMESTAMP', headers) + self.assertIn('CB-ACCESS-SIGN', headers) + + # Check output + + self.assertIsNotNone(product_book) + + pricebook = product_book.pricebook + self.assertEqual(len(pricebook.asks), 5) + self.assertEqual(len(pricebook.bids), 5) + @mock.patch("coinbaseadvanced.client.requests.get") def test_get_trades(self, mock_get): @@ -986,3 +1048,37 @@ def test_get_transactions_summary(self, mock_get): self.assertIsNotNone(transactions_summary.fee_tier) self.assertIsNotNone(transactions_summary.total_fees) self.assertIsNotNone(transactions_summary.total_volume) + + @mock.patch("coinbaseadvanced.client.requests.get") + def test_get_unix_time(self, mock_get): + + mock_resp = fixture_get_unix_time_success_response() + mock_get.return_value = mock_resp + + client = CoinbaseAdvancedTradeAPIClient( + api_key='kjsldfk32234', secret_key='jlsjljsfd89y98y98shdfjksfd') + + unix_time = client.get_unix_time() + + # Check input + + call_args = mock_get.call_args_list + + for call in call_args: + args, kwargs = call + self.assertIn( + 'https://api.coinbase.com/api/v3/brokerage/time', + args) + + headers = kwargs['headers'] + self.assertIn('accept', headers) + self.assertIn('CB-ACCESS-KEY', headers) + self.assertIn('CB-ACCESS-TIMESTAMP', headers) + self.assertIn('CB-ACCESS-SIGN', headers) + + # Check output + + self.assertIsNotNone(unix_time) + self.assertIsNotNone(unix_time.iso) + self.assertIsNotNone(unix_time.epochSeconds) + self.assertIsNotNone(unix_time.epochMillis)