From 71731ba7ecf07127ede974ec08cf0d610d744cd7 Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Sun, 23 Jun 2024 10:53:18 -0500 Subject: [PATCH 01/23] Removed legacy function that are included in SDK --- coinbase_advanced_trader/cb_auth.py | 104 -------- coinbase_advanced_trader/coinbase_client.py | 232 ------------------ coinbase_advanced_trader/config.py | 35 +-- .../strategies/market_order_strategies.py | 55 ----- coinbase_advanced_trader/strategies/utils.py | 37 --- 5 files changed, 1 insertion(+), 462 deletions(-) delete mode 100644 coinbase_advanced_trader/cb_auth.py delete mode 100644 coinbase_advanced_trader/coinbase_client.py delete mode 100644 coinbase_advanced_trader/strategies/market_order_strategies.py delete mode 100644 coinbase_advanced_trader/strategies/utils.py diff --git a/coinbase_advanced_trader/cb_auth.py b/coinbase_advanced_trader/cb_auth.py deleted file mode 100644 index 3b75ba3..0000000 --- a/coinbase_advanced_trader/cb_auth.py +++ /dev/null @@ -1,104 +0,0 @@ -import http.client -import hmac -import hashlib -import json -import time -from urllib.parse import urlencode -from typing import Union, Dict - - -class CBAuth: - """ - Singleton class for Coinbase authentication. - """ - - _instance = None # Class attribute to hold the singleton instance - - def __new__(cls): - """ - Override the __new__ method to control the object creation process. - :return: A single instance of CBAuth - """ - if cls._instance is None: - print("Authenticating with Coinbase") - cls._instance = super(CBAuth, cls).__new__(cls) - cls._instance.init() - return cls._instance - - def init(self): - """ - Initialize the CBAuth instance with API credentials. - """ - self.key = None - self.secret = None - - def set_credentials(self, api_key, api_secret): - """ - Update the API credentials used for authentication. - :param api_key: The API Key for Coinbase API - :param api_secret: The API Secret for Coinbase API - """ - self.key = api_key - self.secret = api_secret - - def __call__(self, method: str, path: str, body: Union[Dict, str] = '', params: Dict[str, str] = None) -> Dict: - """ - Prepare and send an authenticated request to the Coinbase API. - - :param method: HTTP method (e.g., 'GET', 'POST') - :param path: API endpoint path - :param body: Request payload - :param params: URL parameters - :return: Response from the Coinbase API as a dictionary - """ - path = self.add_query_params(path, params) - body_encoded = self.prepare_body(body) - headers = self.create_headers(method, path, body) - return self.send_request(method, path, body_encoded, headers) - - def add_query_params(self, path, params): - if params: - query_params = urlencode(params) - path = f'{path}?{query_params}' - return path - - def prepare_body(self, body): - return json.dumps(body).encode('utf-8') if body else b'' - - def create_headers(self, method, path, body): - timestamp = str(int(time.time())) - message = timestamp + method.upper() + \ - path.split('?')[0] + (json.dumps(body) if body else '') - signature = hmac.new(self.secret.encode( - 'utf-8'), message.encode('utf-8'), digestmod=hashlib.sha256).hexdigest() - - return { - "Content-Type": "application/json", - "CB-ACCESS-KEY": self.key, - "CB-ACCESS-SIGN": signature, - "CB-ACCESS-TIMESTAMP": timestamp - } - - def send_request(self, method, path, body_encoded, headers): - conn = http.client.HTTPSConnection("api.coinbase.com") - try: - conn.request(method, path, body_encoded, headers) - res = conn.getresponse() - data = res.read() - - if res.status == 401: - print("Error: Unauthorized. Please check your API key and secret.") - return None - - response_data = json.loads(data.decode("utf-8")) - if 'error_details' in response_data and response_data['error_details'] == 'missing required scopes': - print( - "Error: Missing Required Scopes. Please update your API Keys to include more permissions.") - return None - - return response_data - except json.JSONDecodeError: - print("Error: Unable to decode JSON response. Raw response data:", data) - return None - finally: - conn.close() diff --git a/coinbase_advanced_trader/coinbase_client.py b/coinbase_advanced_trader/coinbase_client.py deleted file mode 100644 index b68571e..0000000 --- a/coinbase_advanced_trader/coinbase_client.py +++ /dev/null @@ -1,232 +0,0 @@ -from enum import Enum -from datetime import datetime -import uuid -import json -from cb_auth import CBAuth - -# Initialize the single instance of CBAuth -cb_auth = CBAuth() - - -class Side(Enum): - BUY = 1 - SELL = 0 - - -class Method(Enum): - POST = "POST" - GET = "GET" - - -def generate_client_order_id(): - return str(uuid.uuid4()) - - -def listAccounts(limit=49, cursor=None): - """ - Get a list of authenticated accounts for the current user. - - This function uses the GET method to retrieve a list of authenticated accounts from the Coinbase Advanced Trade API. - - :param limit: A pagination limit with default of 49 and maximum of 250. If has_next is true, additional orders are available to be fetched with pagination and the cursor value in the response can be passed as cursor parameter in the subsequent request. - :param cursor: Cursor used for pagination. When provided, the response returns responses after this cursor. - :return: A dictionary containing the response from the server. A successful response will return a 200 status code. An unexpected error will return a default error response. - """ - return cb_auth(Method.GET.value, '/api/v3/brokerage/accounts', {'limit': limit, 'cursor': cursor}) - - -def getAccount(account_uuid): - """ - Get a list of information about an account, given an account UUID. - - This function uses the GET method to retrieve information about an account from the Coinbase Advanced Trade API. - - :param account_uuid: The account's UUID. Use listAccounts() to find account UUIDs. - :return: A dictionary containing the response from the server. A successful response will return a 200 status code. An unexpected error will return a default error response. - """ - return cb_auth(Method.GET.value, f'/api/v3/brokerage/accounts/{account_uuid}') - - -def createOrder(client_order_id, product_id, side, order_type, order_configuration): - """ - Create an order with the given parameters. - - :param client_order_id: A unique ID generated by the client for this order. - :param product_id: The ID of the product to order. - :param side: The side of the order (e.g., 'buy' or 'sell'). - :param order_type: The type of order (e.g., 'limit_limit_gtc'). - :param order_configuration: A dictionary containing order details such as price, size, and post_only. - :return: A dictionary containing the response from the server. - """ - payload = { - "client_order_id": client_order_id, - "product_id": product_id, - "side": side, - "order_configuration": { - order_type: order_configuration - } - } - # print("Payload being sent to server:", payload) # For debugging - return cb_auth(Method.POST.value, '/api/v3/brokerage/orders', payload) - - -def cancelOrders(order_ids): - """ - Initiate cancel requests for one or more orders. - - This function uses the POST method to initiate cancel requests for one or more orders on the Coinbase Advanced Trade API. - - :param order_ids: A list of order IDs for which cancel requests should be initiated. - :return: A dictionary containing the response from the server. A successful response will return a 200 status code. An unexpected error will return a default error response. - """ - body = json.dumps({"order_ids": order_ids}) - return cb_auth(Method.POST.value, '/api/v3/brokerage/orders/batch_cancel', body) - - -def listOrders(**kwargs): - """ - Retrieve a list of historical orders. - - This function uses the GET method to retrieve a list of historical orders from the Coinbase Advanced Trade API. - The orders are returned in a batch format. - - :param kwargs: Optional parameters that can be passed to the API. These can include: - 'product_id': Optional string of the product ID. Defaults to null, or fetch for all products. - 'order_status': A list of order statuses. - 'limit': A pagination limit with no default set. - 'start_date': Start date to fetch orders from, inclusive. - 'end_date': An optional end date for the query window, exclusive. - 'user_native_currency': (Deprecated) String of the users native currency. Default is `USD`. - 'order_type': Type of orders to return. Default is to return all order types. - 'order_side': Only orders matching this side are returned. Default is to return all sides. - 'cursor': Cursor used for pagination. - 'product_type': Only orders matching this product type are returned. Default is to return all product types. - 'order_placement_source': Only orders matching this placement source are returned. Default is to return RETAIL_ADVANCED placement source. - 'contract_expiry_type': Only orders matching this contract expiry type are returned. Filter is only applied if ProductType is set to FUTURE in the request. - :return: A dictionary containing the response from the server. This will include details about each order, such as the order ID, product ID, side, type, and status. - """ - return cb_auth(Method.GET.value, '/api/v3/brokerage/orders/historical/batch', params=kwargs) - - -def listFills(**kwargs): - """ - Retrieve a list of fills filtered by optional query parameters. - - This function uses the GET method to retrieve a list of fills from the Coinbase Advanced Trade API. - The fills are returned in a batch format. - - :param kwargs: Optional parameters that can be passed to the API. These can include: - 'order_id': Optional string of the order ID. - 'product_id': Optional string of the product ID. - 'start_sequence_timestamp': Start date. Only fills with a trade time at or after this start date are returned. - 'end_sequence_timestamp': End date. Only fills with a trade time before this start date are returned. - 'limit': Maximum number of fills to return in response. Defaults to 100. - 'cursor': Cursor used for pagination. When provided, the response returns responses after this cursor. - :return: A dictionary containing the response from the server. This will include details about each fill, such as the fill ID, product ID, side, type, and status. - """ - return cb_auth(Method.GET.value, '/api/v3/brokerage/orders/historical/fills', params=kwargs) - - -def getOrder(order_id): - """ - Retrieve a single order by order ID. - - This function uses the GET method to retrieve a single order from the Coinbase Advanced Trade API. - - :param order_id: The ID of the order to retrieve. - :return: A dictionary containing the response from the server. This will include details about the order, such as the order ID, product ID, side, type, and status. - """ - return cb_auth(Method.GET.value, f'/api/v3/brokerage/orders/historical/{order_id}') - - -def listProducts(**kwargs): - """ - Get a list of the available currency pairs for trading. - - This function uses the GET method to retrieve a list of products from the Coinbase Advanced Trade API. - - :param limit: An optional integer describing how many products to return. Default is None. - :param offset: An optional integer describing the number of products to offset before returning. Default is None. - :param product_type: An optional string describing the type of products to return. Default is None. - :param product_ids: An optional list of strings describing the product IDs to return. Default is None. - :param contract_expiry_type: An optional string describing the contract expiry type. Default is 'UNKNOWN_CONTRACT_EXPIRY_TYPE'. - :return: A dictionary containing the response from the server. This will include details about each product, such as the product ID, product type, and contract expiry type. - """ - return cb_auth(Method.GET.value, '/api/v3/brokerage/products', params=kwargs) - - -def getProduct(product_id): - """ - Get information on a single product by product ID. - - This function uses the GET method to retrieve information about a single product from the Coinbase Advanced Trade API. - - :param product_id: The ID of the product to retrieve information for. - :return: A dictionary containing the response from the server. This will include details about the product, such as the product ID, product type, and contract expiry type. - """ - response = cb_auth( - Method.GET.value, f'/api/v3/brokerage/products/{product_id}') - - # Check if there's an error in the response - if 'error' in response and response['error'] == 'PERMISSION_DENIED': - print( - f"Error: {response['message']}. Details: {response['error_details']}") - return None - - return response - - -def getProductCandles(product_id, start, end, granularity): - """ - Get rates for a single product by product ID, grouped in buckets. - - This function uses the GET method to retrieve rates for a single product from the Coinbase Advanced Trade API. - - :param product_id: The trading pair. - :param start: Timestamp for starting range of aggregations, in UNIX time. - :param end: Timestamp for ending range of aggregations, in UNIX time. - :param granularity: The time slice value for each candle. - :return: A dictionary containing the response from the server. This will include details about each candle, such as the open, high, low, close, and volume. - """ - params = { - 'start': start, - 'end': end, - 'granularity': granularity - } - return cb_auth(Method.GET.value, f'/api/v3/brokerage/products/{product_id}/candles', params=params) - - -def getMarketTrades(product_id, limit): - """ - Get snapshot information, by product ID, about the last trades (ticks), best bid/ask, and 24h volume. - - This function uses the GET method to retrieve snapshot information about the last trades from the Coinbase Advanced Trade API. - - :param product_id: The trading pair, i.e., 'BTC-USD'. - :param limit: Number of trades to return. - :return: A dictionary containing the response from the server. This will include details about the last trades, such as the best bid/ask, and 24h volume. - """ - return cb_auth(Method.GET.value, f'/api/v3/brokerage/products/{product_id}/ticker', {'limit': limit}) - - -def getTransactionsSummary(start_date, end_date, user_native_currency='USD', product_type='SPOT', contract_expiry_type='UNKNOWN_CONTRACT_EXPIRY_TYPE'): - """ - Get a summary of transactions with fee tiers, total volume, and fees. - - This function uses the GET method to retrieve a summary of transactions from the Coinbase Advanced Trade API. - - :param start_date: The start date of the transactions to retrieve, in datetime format. - :param end_date: The end date of the transactions to retrieve, in datetime format. - :param user_native_currency: The user's native currency. Default is 'USD'. - :param product_type: The type of product. Default is 'SPOT'. - :param contract_expiry_type: Only orders matching this contract expiry type are returned. Only filters response if ProductType is set to 'FUTURE'. Default is 'UNKNOWN_CONTRACT_EXPIRY_TYPE'. - :return: A dictionary containing the response from the server. This will include details about each transaction, such as fee tiers, total volume, and fees. - """ - params = { - 'start_date': start_date.strftime('%Y-%m-%dT%H:%M:%SZ'), - 'end_date': end_date.strftime('%Y-%m-%dT%H:%M:%SZ'), - 'user_native_currency': user_native_currency, - 'product_type': product_type, - 'contract_expiry_type': contract_expiry_type - } - return cb_auth(Method.GET.value, '/api/v3/brokerage/transaction_summary', params) diff --git a/coinbase_advanced_trader/config.py b/coinbase_advanced_trader/config.py index 52f085d..e20c521 100644 --- a/coinbase_advanced_trader/config.py +++ b/coinbase_advanced_trader/config.py @@ -1,9 +1,3 @@ -import os -from coinbase_advanced_trader.cb_auth import CBAuth - -API_KEY = None -API_SECRET = None - # Default price multipliers for limit orders BUY_PRICE_MULTIPLIER = 0.995 SELL_PRICE_MULTIPLIER = 1.005 @@ -22,31 +16,4 @@ {'threshold': 70, 'factor': 0.9, 'action': 'sell'}, {'threshold': 80, 'factor': 0.7, 'action': 'sell'}, {'threshold': 90, 'factor': 0.5, 'action': 'sell'} -] - - -def set_api_credentials(api_key=None, api_secret=None): - global API_KEY - global API_SECRET - - # Option 1: Use provided arguments - if api_key and api_secret: - API_KEY = api_key - API_SECRET = api_secret - - # Option 2: Use environment variables - elif 'COINBASE_API_KEY' in os.environ and 'COINBASE_API_SECRET' in os.environ: - API_KEY = os.environ['COINBASE_API_KEY'] - API_SECRET = os.environ['COINBASE_API_SECRET'] - - # Option 3: Load from a separate file (e.g., keys.txt) - else: - try: - with open('keys.txt', 'r') as f: - API_KEY = f.readline().strip() - API_SECRET = f.readline().strip() - except FileNotFoundError: - print("Error: API keys not found. Please set your API keys.") - - # Update the CBAuth singleton instance with the new credentials - CBAuth().set_credentials(API_KEY, API_SECRET) +] \ No newline at end of file diff --git a/coinbase_advanced_trader/strategies/market_order_strategies.py b/coinbase_advanced_trader/strategies/market_order_strategies.py deleted file mode 100644 index bfb1d87..0000000 --- a/coinbase_advanced_trader/strategies/market_order_strategies.py +++ /dev/null @@ -1,55 +0,0 @@ -from decimal import Decimal, ROUND_HALF_UP -from coinbase_advanced_trader.cb_auth import CBAuth -from .utils import get_spot_price -from coinbase_advanced_trader.coinbase_client import createOrder, generate_client_order_id, Side, getProduct - -cb_auth = CBAuth() - - -def calculate_base_size(fiat_amount, spot_price, base_increment): - base_size = (fiat_amount / spot_price / base_increment).quantize( - Decimal('1'), rounding=ROUND_HALF_UP) * base_increment - return base_size - - -def _market_order(product_id, fiat_amount, side): - product_details = getProduct(product_id) - base_increment = Decimal(product_details['base_increment']) - spot_price = get_spot_price(product_id) - - if side == Side.SELL.name: - base_size = calculate_base_size( - fiat_amount, spot_price, base_increment) - order_configuration = {'base_size': str(base_size)} - else: - order_configuration = {'quote_size': str(fiat_amount)} - - order_details = createOrder( - client_order_id=generate_client_order_id(), - product_id=product_id, - side=side, - order_type='market_market_ioc', - order_configuration=order_configuration - ) - - if order_details['success']: - print( - f"Successfully placed a {side} order for {fiat_amount} USD of {product_id.split('-')[0]}.") - else: - failure_reason = order_details.get('failure_reason', '') - preview_failure_reason = order_details.get( - 'error_response', {}).get('preview_failure_reason', '') - print( - f"Failed to place a {side} order. Reason: {failure_reason}. Preview failure reason: {preview_failure_reason}") - - print("Coinbase response:", order_details) - - return order_details - - -def fiat_market_buy(product_id, fiat_amount): - return _market_order(product_id, fiat_amount, Side.BUY.name) - - -def fiat_market_sell(product_id, fiat_amount): - return _market_order(product_id, fiat_amount, Side.SELL.name) diff --git a/coinbase_advanced_trader/strategies/utils.py b/coinbase_advanced_trader/strategies/utils.py deleted file mode 100644 index 9445882..0000000 --- a/coinbase_advanced_trader/strategies/utils.py +++ /dev/null @@ -1,37 +0,0 @@ -from coinbase_advanced_trader.cb_auth import CBAuth -import coinbase_advanced_trader.coinbase_client as client -from decimal import Decimal - -# Get the singleton instance of CBAuth -cb_auth = CBAuth() - - -def get_spot_price(product_id): - """ - Fetches the current spot price of a specified product. - - Args: - product_id (str): The ID of the product (e.g., "BTC-USD"). - - Returns: - float: The spot price as a float, or None if an error occurs. - """ - try: - response = client.getProduct(product_id) - # print("Response:", response) # Log the entire response for debugging - quote_increment = Decimal(response['quote_increment']) - - # Check whether the 'price' field exists in the response and return it as a float - if 'price' in response: - price = Decimal(response['price']) - # Round the price to quote_increment number of digits - rounded_price = price.quantize(quote_increment) - return rounded_price - else: - # Print a specific error message if the 'price' field is missing - print(f"'price' field missing in response for {product_id}") - return None - - except Exception as e: - print(f"Error fetching spot price for {product_id}: {e}") - return None From 08a3555259965f86c71c86b935cf6c43f8c951e1 Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Sun, 23 Jun 2024 10:53:29 -0500 Subject: [PATCH 02/23] Rewrote fiat market buy/sell --- .../EnhancedRESTClient.py | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 coinbase_advanced_trader/EnhancedRESTClient.py diff --git a/coinbase_advanced_trader/EnhancedRESTClient.py b/coinbase_advanced_trader/EnhancedRESTClient.py new file mode 100644 index 0000000..d792e81 --- /dev/null +++ b/coinbase_advanced_trader/EnhancedRESTClient.py @@ -0,0 +1,90 @@ +import uuid +from coinbase.rest import RESTClient +from decimal import Decimal, ROUND_HALF_UP +from enum import Enum + +class Side(Enum): + BUY = 1 + SELL = 0 + +class EnhancedRESTClient(RESTClient): + def __init__(self, api_key, api_secret, **kwargs): + super().__init__(api_key=api_key, api_secret=api_secret, **kwargs) + + def generate_client_order_id(self): + return str(uuid.uuid4()) + + def get_spot_price(self, product_id): + """ + Fetches the current spot price of a specified product. + + Args: + product_id (str): The ID of the product (e.g., "BTC-USD"). + + Returns: + float: The spot price as a float, or None if an error occurs. + """ + try: + response = self.get_product(product_id) + # print("Response:", response) # Log the entire response for debugging + quote_increment = Decimal(response['quote_increment']) + + # Check whether the 'price' field exists in the response and return it as a float + if 'price' in response: + price = Decimal(response['price']) + # Round the price to quote_increment number of digits + rounded_price = price.quantize(quote_increment) + return rounded_price + else: + # Print a specific error message if the 'price' field is missing + print(f"'price' field missing in response for {product_id}") + return None + + except Exception as e: + print(f"Error fetching spot price for {product_id}: {e}") + return None + + def calculate_base_size(self, fiat_amount, spot_price, base_increment): + print(fiat_amount) + fiat_amount_decimal = Decimal(fiat_amount) + base_size = (fiat_amount_decimal / Decimal(spot_price) / Decimal(base_increment)).quantize( + Decimal('1'), rounding=ROUND_HALF_UP) * Decimal(base_increment) + print(base_size) + return str(base_size) + + def fiat_market_buy(self, product_id, fiat_amount): + #Example: client.fiat_market_buy("BTC-USDC","10") + order = self.market_order_buy(self.generate_client_order_id(), product_id, fiat_amount) + if order['success']: + print(f"Successfully placed a buy order for {fiat_amount} {product_id.split('-')[1]} of {product_id.split('-')[0]}.") + else: + failure_reason = order.get('failure_reason', '') + preview_failure_reason = order.get( + 'error_response', {}).get('preview_failure_reason', '') + print( + f"Failed to place a buy order. Reason: {failure_reason}. Preview failure reason: {preview_failure_reason}") + + print("Coinbase response:", order) + return order + + def fiat_market_sell(self, product_id, fiat_amount): + #Example: client.fiat_market_sell("BTC-USDC","10") + base_size = self.calculate_base_size(fiat_amount, self.get_spot_price(product_id), self.get_product(product_id)['base_increment']) + order = self.market_order_sell(self.generate_client_order_id(), product_id, base_size) + if order['success']: + print(f"Successfully placed a sell order for {fiat_amount} {product_id.split('-')[1]} of {product_id.split('-')[0]}.") + else: + failure_reason = order.get('failure_reason', '') + preview_failure_reason = order.get( + 'error_response', {}).get('preview_failure_reason', '') + print( + f"Failed to place a sell order. Reason: {failure_reason}. Preview failure reason: {preview_failure_reason}") + + print("Coinbase response:", order) + return order + + + + + + \ No newline at end of file From 3f4ad8aec725b0d835105a267b15d39ed3a92ac4 Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Mon, 24 Jun 2024 16:50:41 -0500 Subject: [PATCH 03/23] Updated versions of limit buy / limit sell --- .../EnhancedRESTClient.py | 114 +++++++++++++ .../strategies/limit_order_strategies.py | 154 ------------------ 2 files changed, 114 insertions(+), 154 deletions(-) delete mode 100644 coinbase_advanced_trader/strategies/limit_order_strategies.py diff --git a/coinbase_advanced_trader/EnhancedRESTClient.py b/coinbase_advanced_trader/EnhancedRESTClient.py index d792e81..8a2f43e 100644 --- a/coinbase_advanced_trader/EnhancedRESTClient.py +++ b/coinbase_advanced_trader/EnhancedRESTClient.py @@ -1,5 +1,6 @@ import uuid from coinbase.rest import RESTClient +from coinbase_advanced_trader.config import BUY_PRICE_MULTIPLIER, SELL_PRICE_MULTIPLIER from decimal import Decimal, ROUND_HALF_UP from enum import Enum @@ -83,8 +84,121 @@ def fiat_market_sell(self, product_id, fiat_amount): print("Coinbase response:", order) return order + def fiat_limit_buy(self,product_id, fiat_amount, price_multiplier=BUY_PRICE_MULTIPLIER): + """ + Places a limit buy order. + + Args: + product_id (str): The ID of the product to buy (e.g., "BTC-USD"). + fiat_amount (float): The amount in USD or other fiat to spend on buying (ie. $200). + price_multiplier (float, optional): Multiplier to apply to the current spot price to get the limit price. Defaults to BUY_PRICE_MULTIPLIER. + + Returns: + dict: The response of the order details. + """ + # Coinbase maker fee rate + maker_fee_rate = Decimal('0.004') + + # Fetch product details to get the quote_increment and base_increment + product_details = self.get_product(product_id) + quote_increment = Decimal(product_details['quote_increment']) + base_increment = Decimal(product_details['base_increment']) + + # Fetch the current spot price for the product + spot_price = self.get_spot_price(product_id) + + # Calculate the limit price + limit_price = Decimal(spot_price) * Decimal(price_multiplier) + + # Round the limit price to the appropriate number of decimal places + limit_price = limit_price.quantize(quote_increment) + + # Adjust the fiat_amount for the maker fee + effective_fiat_amount = Decimal(fiat_amount) * (1 - maker_fee_rate) + + # Calculate the equivalent amount in the base currency (e.g., BTC) for the given USD amount + base_size = effective_fiat_amount / limit_price + # Round base_size to the nearest allowed increment + base_size = (base_size / base_increment).quantize(Decimal('1'), + rounding=ROUND_HALF_UP) * base_increment + order = self.limit_order_gtc_buy(self.generate_client_order_id(),product_id, str(base_size), str(limit_price)) + # Print a human-readable message + if order['success']: + base_size = Decimal( + order['order_configuration']['limit_limit_gtc']['base_size']) + limit_price = Decimal( + order['order_configuration']['limit_limit_gtc']['limit_price']) + total_amount = base_size * limit_price + print( + f"Successfully placed a limit buy order for {base_size} {product_id} (${total_amount:.2f}) at a price of {limit_price} USD.") + else: + print( + f"Failed to place a limit buy order. Reason: {order['failure_reason']}") + + print("Coinbase response:", order) + + return order + + + def fiat_limit_sell(self, product_id, fiat_amount, price_multiplier=SELL_PRICE_MULTIPLIER): + """ + Places a limit sell order. + + Args: + product_id (str): The ID of the product to sell (e.g., "BTC-USD"). + fiat_amount (float): The amount in USD or other fiat to receive from selling (ie. $200). + price_multiplier (float, optional): Multiplier to apply to the current spot price to get the limit price. Defaults to SELL_PRICE_MULTIPLIER. + + Returns: + dict: The response of the order details. + """ + # Coinbase maker fee rate + maker_fee_rate = Decimal('0.004') + + # Fetch product details to get the quote_increment and base_increment + product_details = self.get_product(product_id) + quote_increment = Decimal(product_details['quote_increment']) + base_increment = Decimal(product_details['base_increment']) + + # Fetch the current spot price for the product + spot_price = self.get_spot_price(product_id) + + # Calculate the limit price + limit_price = Decimal(spot_price) * Decimal(price_multiplier) + + # Round the limit price to the appropriate number of decimal places + limit_price = limit_price.quantize(quote_increment) + + # Adjust the fiat_amount for the maker fee + effective_fiat_amount = Decimal(fiat_amount) / (1 - maker_fee_rate) + + # Calculate the equivalent amount in the base currency (e.g., BTC) for the given USD amount + base_size = effective_fiat_amount / limit_price + + # Round base_size to the nearest allowed increment + base_size = (base_size / base_increment).quantize(Decimal('1'), + rounding=ROUND_HALF_UP) * base_increment + + order = self.limit_order_gtc_sell(self.generate_client_order_id(),product_id, str(base_size), str(limit_price)) + + # Print a human-readable message + if order['success']: + base_size = Decimal( + order['order_configuration']['limit_limit_gtc']['base_size']) + limit_price = Decimal( + order['order_configuration']['limit_limit_gtc']['limit_price']) + total_amount = base_size * limit_price + print( + f"Successfully placed a limit sell order for {base_size} {product_id} (${total_amount:.2f}) at a price of {limit_price} USD.") + else: + print( + f"Failed to place a limit sell order. Reason: {order['failure_reason']}") + + print("Coinbase response:", order) + + return order \ No newline at end of file diff --git a/coinbase_advanced_trader/strategies/limit_order_strategies.py b/coinbase_advanced_trader/strategies/limit_order_strategies.py deleted file mode 100644 index 12382e4..0000000 --- a/coinbase_advanced_trader/strategies/limit_order_strategies.py +++ /dev/null @@ -1,154 +0,0 @@ -from decimal import Decimal, ROUND_HALF_UP -from .utils import get_spot_price -from coinbase_advanced_trader.cb_auth import CBAuth -from coinbase_advanced_trader.config import BUY_PRICE_MULTIPLIER, SELL_PRICE_MULTIPLIER -from coinbase_advanced_trader.coinbase_client import createOrder, generate_client_order_id, Side, getProduct - -# Initialize the single instance of CBAuth -cb_auth = CBAuth() - - -def fiat_limit_buy(product_id, fiat_amount, price_multiplier=BUY_PRICE_MULTIPLIER): - """ - Places a limit buy order. - - Args: - product_id (str): The ID of the product to buy (e.g., "BTC-USD"). - fiat_amount (float): The amount in USD or other fiat to spend on buying (ie. $200). - price_multiplier (float, optional): Multiplier to apply to the current spot price to get the limit price. Defaults to BUY_PRICE_MULTIPLIER. - - Returns: - dict: The response of the order details. - """ - # Coinbase maker fee rate - maker_fee_rate = Decimal('0.004') - - # Fetch product details to get the quote_increment and base_increment - product_details = getProduct(product_id) - quote_increment = Decimal(product_details['quote_increment']) - base_increment = Decimal(product_details['base_increment']) - - # Fetch the current spot price for the product - spot_price = get_spot_price(product_id) - - # Calculate the limit price - limit_price = Decimal(spot_price) * Decimal(price_multiplier) - - # Round the limit price to the appropriate number of decimal places - limit_price = limit_price.quantize(quote_increment) - - # Adjust the fiat_amount for the maker fee - effective_fiat_amount = Decimal(fiat_amount) * (1 - maker_fee_rate) - - # Calculate the equivalent amount in the base currency (e.g., BTC) for the given USD amount - base_size = effective_fiat_amount / limit_price - - # Round base_size to the nearest allowed increment - base_size = (base_size / base_increment).quantize(Decimal('1'), - rounding=ROUND_HALF_UP) * base_increment - - # Create order configuration - order_configuration = { - 'limit_price': str(limit_price), - 'base_size': str(base_size), - 'post_only': True - } - - # Send the order - order_details = createOrder( - client_order_id=generate_client_order_id(), - product_id=product_id, - side=Side.BUY.name, - order_type='limit_limit_gtc', - order_configuration=order_configuration - ) - - # Print a human-readable message - if order_details['success']: - base_size = Decimal( - order_details['order_configuration']['limit_limit_gtc']['base_size']) - limit_price = Decimal( - order_details['order_configuration']['limit_limit_gtc']['limit_price']) - total_amount = base_size * limit_price - print( - f"Successfully placed a limit buy order for {base_size} {product_id} (${total_amount:.2f}) at a price of {limit_price} USD.") - else: - print( - f"Failed to place a limit buy order. Reason: {order_details['failure_reason']}") - - print("Coinbase response:", order_details) - - return order_details - - -def fiat_limit_sell(product_id, fiat_amount, price_multiplier=SELL_PRICE_MULTIPLIER): - """ - Places a limit sell order. - - Args: - product_id (str): The ID of the product to sell (e.g., "BTC-USD"). - fiat_amount (float): The amount in USD or other fiat to receive from selling (ie. $200). - price_multiplier (float, optional): Multiplier to apply to the current spot price to get the limit price. Defaults to SELL_PRICE_MULTIPLIER. - - Returns: - dict: The response of the order details. - """ - # Coinbase maker fee rate - maker_fee_rate = Decimal('0.004') - - # Fetch product details to get the quote_increment and base_increment - product_details = getProduct(product_id) - quote_increment = Decimal(product_details['quote_increment']) - base_increment = Decimal(product_details['base_increment']) - - # Fetch the current spot price for the product - spot_price = get_spot_price(product_id) - - # Calculate the limit price - limit_price = Decimal(spot_price) * Decimal(price_multiplier) - - # Round the limit price to the appropriate number of decimal places - limit_price = limit_price.quantize(quote_increment) - - # Adjust the fiat_amount for the maker fee - effective_fiat_amount = Decimal(fiat_amount) / (1 - maker_fee_rate) - - # Calculate the equivalent amount in the base currency (e.g., BTC) for the given USD amount - base_size = effective_fiat_amount / limit_price - - # Round base_size to the nearest allowed increment - base_size = (base_size / base_increment).quantize(Decimal('1'), - rounding=ROUND_HALF_UP) * base_increment - - # Create order configuration - order_configuration = { - 'limit_price': str(limit_price), - 'base_size': str(base_size), - 'post_only': True - } - - # Send the order - order_details = createOrder( - client_order_id=generate_client_order_id(), - product_id=product_id, - side=Side.SELL.name, - order_type='limit_limit_gtc', - order_configuration=order_configuration - ) - - # Print a human-readable message - if order_details['success']: - base_size = Decimal( - order_details['order_configuration']['limit_limit_gtc']['base_size']) - limit_price = Decimal( - order_details['order_configuration']['limit_limit_gtc']['limit_price']) - total_amount = base_size * limit_price - print( - f"Successfully placed a limit sell order for {base_size} {product_id} (${total_amount:.2f}) at a price of {limit_price} USD.") - else: - print( - f"Failed to place a limit sell order. Reason: {order_details['failure_reason']}") - - print("Coinbase response:", order_details) - - return order_details From 8667118b2a587782d61a7ea47432dec3450230c5 Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Mon, 24 Jun 2024 17:20:51 -0500 Subject: [PATCH 04/23] Update .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5e524d9..0a3049a 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,5 @@ tests/__pycache__/ .pytest_cache .vscode/ test.py -promptlib/ \ No newline at end of file +promptlib/ +coinbase_advanced_trader.log From ee951b2986311229d27904da294c3c79eeb9c4f6 Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Mon, 24 Jun 2024 17:21:04 -0500 Subject: [PATCH 05/23] Added logger --- coinbase_advanced_trader/logger.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 coinbase_advanced_trader/logger.py diff --git a/coinbase_advanced_trader/logger.py b/coinbase_advanced_trader/logger.py new file mode 100644 index 0000000..00222bb --- /dev/null +++ b/coinbase_advanced_trader/logger.py @@ -0,0 +1,26 @@ +import logging +import sys + +def setup_logger(): + logger = logging.getLogger('coinbase_advanced_trader') + logger.setLevel(logging.DEBUG) + + # Console handler + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(logging.INFO) + console_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') + console_handler.setFormatter(console_formatter) + + # File handler + file_handler = logging.FileHandler('coinbase_advanced_trader.log') + file_handler.setLevel(logging.DEBUG) + file_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s') + file_handler.setFormatter(file_formatter) + + # Add handlers to logger + logger.addHandler(console_handler) + logger.addHandler(file_handler) + + return logger + +logger = setup_logger() \ No newline at end of file From ba09f6b42ce1999bfb279a25afe7f2916853d3fd Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Mon, 24 Jun 2024 17:21:23 -0500 Subject: [PATCH 06/23] Refactored to reuse code in Enhanced Rest Client --- .../EnhancedRESTClient.py | 224 +++++++----------- 1 file changed, 92 insertions(+), 132 deletions(-) diff --git a/coinbase_advanced_trader/EnhancedRESTClient.py b/coinbase_advanced_trader/EnhancedRESTClient.py index 8a2f43e..ceefd2d 100644 --- a/coinbase_advanced_trader/EnhancedRESTClient.py +++ b/coinbase_advanced_trader/EnhancedRESTClient.py @@ -1,21 +1,24 @@ import uuid -from coinbase.rest import RESTClient -from coinbase_advanced_trader.config import BUY_PRICE_MULTIPLIER, SELL_PRICE_MULTIPLIER +from typing import Dict, Any from decimal import Decimal, ROUND_HALF_UP from enum import Enum +from coinbase.rest import RESTClient +from coinbase_advanced_trader.config import BUY_PRICE_MULTIPLIER, SELL_PRICE_MULTIPLIER +from coinbase_advanced_trader.logger import logger class Side(Enum): BUY = 1 SELL = 0 class EnhancedRESTClient(RESTClient): - def __init__(self, api_key, api_secret, **kwargs): + def __init__(self, api_key: str, api_secret: str, **kwargs): super().__init__(api_key=api_key, api_secret=api_secret, **kwargs) + self.MAKER_FEE_RATE = Decimal('0.004') - def generate_client_order_id(self): + def _generate_client_order_id(self) -> str: return str(uuid.uuid4()) - def get_spot_price(self, product_id): + def get_spot_price(self, product_id: str) -> Decimal: """ Fetches the current spot price of a specified product. @@ -23,182 +26,139 @@ def get_spot_price(self, product_id): product_id (str): The ID of the product (e.g., "BTC-USD"). Returns: - float: The spot price as a float, or None if an error occurs. + Decimal: The spot price as a Decimal, or None if an error occurs. """ try: response = self.get_product(product_id) - # print("Response:", response) # Log the entire response for debugging quote_increment = Decimal(response['quote_increment']) - # Check whether the 'price' field exists in the response and return it as a float if 'price' in response: price = Decimal(response['price']) - # Round the price to quote_increment number of digits - rounded_price = price.quantize(quote_increment) - return rounded_price + return price.quantize(quote_increment) else: - # Print a specific error message if the 'price' field is missing - print(f"'price' field missing in response for {product_id}") + logger.error(f"'price' field missing in response for {product_id}") return None except Exception as e: - print(f"Error fetching spot price for {product_id}: {e}") + logger.error(f"Error fetching spot price for {product_id}: {e}") return None - def calculate_base_size(self, fiat_amount, spot_price, base_increment): - print(fiat_amount) - fiat_amount_decimal = Decimal(fiat_amount) - base_size = (fiat_amount_decimal / Decimal(spot_price) / Decimal(base_increment)).quantize( - Decimal('1'), rounding=ROUND_HALF_UP) * Decimal(base_increment) - print(base_size) - return str(base_size) - - def fiat_market_buy(self, product_id, fiat_amount): - #Example: client.fiat_market_buy("BTC-USDC","10") - order = self.market_order_buy(self.generate_client_order_id(), product_id, fiat_amount) - if order['success']: - print(f"Successfully placed a buy order for {fiat_amount} {product_id.split('-')[1]} of {product_id.split('-')[0]}.") - else: - failure_reason = order.get('failure_reason', '') - preview_failure_reason = order.get( - 'error_response', {}).get('preview_failure_reason', '') - print( - f"Failed to place a buy order. Reason: {failure_reason}. Preview failure reason: {preview_failure_reason}") + def _calculate_base_size(self, fiat_amount: Decimal, spot_price: Decimal, base_increment: Decimal) -> Decimal: + return ((fiat_amount / spot_price) / base_increment).quantize(Decimal('1'), rounding=ROUND_HALF_UP) * base_increment - print("Coinbase response:", order) - return order + def fiat_market_buy(self, product_id: str, fiat_amount: str) -> Dict[str, Any]: + """ + Place a market buy order for a specified fiat amount. - def fiat_market_sell(self, product_id, fiat_amount): - #Example: client.fiat_market_sell("BTC-USDC","10") - base_size = self.calculate_base_size(fiat_amount, self.get_spot_price(product_id), self.get_product(product_id)['base_increment']) - order = self.market_order_sell(self.generate_client_order_id(), product_id, base_size) - if order['success']: - print(f"Successfully placed a sell order for {fiat_amount} {product_id.split('-')[1]} of {product_id.split('-')[0]}.") - else: - failure_reason = order.get('failure_reason', '') - preview_failure_reason = order.get( - 'error_response', {}).get('preview_failure_reason', '') - print( - f"Failed to place a sell order. Reason: {failure_reason}. Preview failure reason: {preview_failure_reason}") + Args: + product_id (str): The ID of the product to buy (e.g., "BTC-USDC"). + fiat_amount (str): The amount of fiat currency to spend. - print("Coinbase response:", order) + Returns: + dict: The order response from Coinbase. + """ + order = self.market_order_buy(self._generate_client_order_id(), product_id, fiat_amount) + self._log_order_result(order, product_id, fiat_amount, Side.BUY) return order - def fiat_limit_buy(self,product_id, fiat_amount, price_multiplier=BUY_PRICE_MULTIPLIER): + def fiat_market_sell(self, product_id: str, fiat_amount: str) -> Dict[str, Any]: """ - Places a limit buy order. + Place a market sell order for a specified fiat amount. Args: - product_id (str): The ID of the product to buy (e.g., "BTC-USD"). - fiat_amount (float): The amount in USD or other fiat to spend on buying (ie. $200). - price_multiplier (float, optional): Multiplier to apply to the current spot price to get the limit price. Defaults to BUY_PRICE_MULTIPLIER. + product_id (str): The ID of the product to sell (e.g., "BTC-USDC"). + fiat_amount (str): The amount of fiat currency to receive. Returns: - dict: The response of the order details. + dict: The order response from Coinbase. """ - # Coinbase maker fee rate - maker_fee_rate = Decimal('0.004') - - # Fetch product details to get the quote_increment and base_increment - product_details = self.get_product(product_id) - quote_increment = Decimal(product_details['quote_increment']) - base_increment = Decimal(product_details['base_increment']) - - # Fetch the current spot price for the product spot_price = self.get_spot_price(product_id) + base_increment = Decimal(self.get_product(product_id)['base_increment']) + base_size = self._calculate_base_size(Decimal(fiat_amount), spot_price, base_increment) + + order = self.market_order_sell(self._generate_client_order_id(), product_id, str(base_size)) + self._log_order_result(order, product_id, fiat_amount, Side.SELL) + return order - # Calculate the limit price - limit_price = Decimal(spot_price) * Decimal(price_multiplier) - - # Round the limit price to the appropriate number of decimal places - limit_price = limit_price.quantize(quote_increment) - - # Adjust the fiat_amount for the maker fee - effective_fiat_amount = Decimal(fiat_amount) * (1 - maker_fee_rate) - - # Calculate the equivalent amount in the base currency (e.g., BTC) for the given USD amount - base_size = effective_fiat_amount / limit_price - - # Round base_size to the nearest allowed increment - base_size = (base_size / base_increment).quantize(Decimal('1'), - rounding=ROUND_HALF_UP) * base_increment - - order = self.limit_order_gtc_buy(self.generate_client_order_id(),product_id, str(base_size), str(limit_price)) - # Print a human-readable message - if order['success']: - base_size = Decimal( - order['order_configuration']['limit_limit_gtc']['base_size']) - limit_price = Decimal( - order['order_configuration']['limit_limit_gtc']['limit_price']) - total_amount = base_size * limit_price - print( - f"Successfully placed a limit buy order for {base_size} {product_id} (${total_amount:.2f}) at a price of {limit_price} USD.") - else: - print( - f"Failed to place a limit buy order. Reason: {order['failure_reason']}") - - print("Coinbase response:", order) + def fiat_limit_buy(self, product_id: str, fiat_amount: str, price_multiplier: float = BUY_PRICE_MULTIPLIER) -> Dict[str, Any]: + """ + Places a limit buy order. - return order + Args: + product_id (str): The ID of the product to buy (e.g., "BTC-USD"). + fiat_amount (str): The amount in fiat currency to spend on buying. + price_multiplier (float, optional): Multiplier to apply to the current spot price to get the limit price. + Defaults to BUY_PRICE_MULTIPLIER. + Returns: + Dict[str, Any]: The response containing order details. + """ + return self._place_limit_order(product_id, fiat_amount, price_multiplier, Side.BUY) - def fiat_limit_sell(self, product_id, fiat_amount, price_multiplier=SELL_PRICE_MULTIPLIER): + def fiat_limit_sell(self, product_id: str, fiat_amount: str, price_multiplier: float = SELL_PRICE_MULTIPLIER) -> Dict[str, Any]: """ Places a limit sell order. Args: product_id (str): The ID of the product to sell (e.g., "BTC-USD"). - fiat_amount (float): The amount in USD or other fiat to receive from selling (ie. $200). - price_multiplier (float, optional): Multiplier to apply to the current spot price to get the limit price. Defaults to SELL_PRICE_MULTIPLIER. + fiat_amount (str): The amount in USD or other fiat to receive from selling (e.g., "200"). + price_multiplier (float, optional): Multiplier to apply to the current spot price to get the limit price. + Defaults to SELL_PRICE_MULTIPLIER. Returns: - dict: The response of the order details. + Dict[str, Any]: The response of the order details. """ - # Coinbase maker fee rate - maker_fee_rate = Decimal('0.004') + return self._place_limit_order(product_id, fiat_amount, price_multiplier, Side.SELL) - # Fetch product details to get the quote_increment and base_increment + def _place_limit_order(self, product_id: str, fiat_amount: str, price_multiplier: float, side: Side) -> Dict[str, Any]: product_details = self.get_product(product_id) quote_increment = Decimal(product_details['quote_increment']) base_increment = Decimal(product_details['base_increment']) - # Fetch the current spot price for the product spot_price = self.get_spot_price(product_id) + limit_price = (Decimal(spot_price) * Decimal(price_multiplier)).quantize(quote_increment) - # Calculate the limit price - limit_price = Decimal(spot_price) * Decimal(price_multiplier) + fiat_amount_decimal = Decimal(fiat_amount) + effective_fiat_amount = fiat_amount_decimal * (1 - self.MAKER_FEE_RATE) if side == Side.BUY else fiat_amount_decimal / (1 - self.MAKER_FEE_RATE) + base_size = (effective_fiat_amount / limit_price).quantize(base_increment, rounding=ROUND_HALF_UP) - # Round the limit price to the appropriate number of decimal places - limit_price = limit_price.quantize(quote_increment) + order_func = self.limit_order_gtc_buy if side == Side.BUY else self.limit_order_gtc_sell + order = order_func( + self._generate_client_order_id(), + product_id, + str(base_size), + str(limit_price) + ) - # Adjust the fiat_amount for the maker fee - effective_fiat_amount = Decimal(fiat_amount) / (1 - maker_fee_rate) + self._log_order_result(order, product_id, base_size, limit_price, side) - # Calculate the equivalent amount in the base currency (e.g., BTC) for the given USD amount - base_size = effective_fiat_amount / limit_price + return order - # Round base_size to the nearest allowed increment - base_size = (base_size / base_increment).quantize(Decimal('1'), - rounding=ROUND_HALF_UP) * base_increment + def _log_order_result(self, order: Dict[str, Any], product_id: str, amount: Any, price: Any = None, side: Side = None) -> None: + """ + Log the result of an order. - order = self.limit_order_gtc_sell(self.generate_client_order_id(),product_id, str(base_size), str(limit_price)) + Args: + order (Dict[str, Any]): The order response from Coinbase. + product_id (str): The ID of the product. + amount (Any): The amount of the order. + price (Any, optional): The price of the order (for limit orders). + side (Side, optional): The side of the order (buy or sell). + """ + base_currency, quote_currency = product_id.split('-') + order_type = "limit" if price else "market" + side_str = side.name.lower() if side else "unknown" - # Print a human-readable message if order['success']: - base_size = Decimal( - order['order_configuration']['limit_limit_gtc']['base_size']) - limit_price = Decimal( - order['order_configuration']['limit_limit_gtc']['limit_price']) - total_amount = base_size * limit_price - print( - f"Successfully placed a limit sell order for {base_size} {product_id} (${total_amount:.2f}) at a price of {limit_price} USD.") + if price: + total_amount = Decimal(amount) * Decimal(price) + logger.info(f"Successfully placed a {order_type} {side_str} order for {amount} {base_currency} " + f"(${total_amount:.2f}) at a price of {price} {quote_currency}.") + else: + logger.info(f"Successfully placed a {order_type} {side_str} order for {amount} {quote_currency} of {base_currency}.") else: - print( - f"Failed to place a limit sell order. Reason: {order['failure_reason']}") - - print("Coinbase response:", order) - - return order - - - \ No newline at end of file + failure_reason = order.get('failure_reason', 'Unknown') + preview_failure_reason = order.get('error_response', {}).get('preview_failure_reason', 'Unknown') + logger.error(f"Failed to place a {order_type} {side_str} order. Reason: {failure_reason}. Preview failure reason: {preview_failure_reason}") + + logger.debug(f"Coinbase response: {order}") \ No newline at end of file From 50a2c911811b0dccb2a2ad312ea76bab4a9afb2b Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Mon, 24 Jun 2024 23:09:03 -0500 Subject: [PATCH 07/23] Removed old test suites --- .../strategies/__init__.py | 0 .../strategies/fear_and_greed_strategies.py | 80 --- coinbase_advanced_trader/tests/__init__.py | 0 .../tests/test_fear_and_greed_strategies.py | 33 - .../tests/test_limit_order_strategies.py | 43 -- .../tests/test_market_order_strategies.py | 43 -- coinbase_advanced_trader/tests/test_utils.py | 42 -- tests/__init__.py | 0 tests/test_coinbase_client.py | 572 ------------------ 9 files changed, 813 deletions(-) delete mode 100644 coinbase_advanced_trader/strategies/__init__.py delete mode 100644 coinbase_advanced_trader/strategies/fear_and_greed_strategies.py delete mode 100644 coinbase_advanced_trader/tests/__init__.py delete mode 100644 coinbase_advanced_trader/tests/test_fear_and_greed_strategies.py delete mode 100644 coinbase_advanced_trader/tests/test_limit_order_strategies.py delete mode 100644 coinbase_advanced_trader/tests/test_market_order_strategies.py delete mode 100644 coinbase_advanced_trader/tests/test_utils.py delete mode 100644 tests/__init__.py delete mode 100644 tests/test_coinbase_client.py diff --git a/coinbase_advanced_trader/strategies/__init__.py b/coinbase_advanced_trader/strategies/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/coinbase_advanced_trader/strategies/fear_and_greed_strategies.py b/coinbase_advanced_trader/strategies/fear_and_greed_strategies.py deleted file mode 100644 index d6d79a7..0000000 --- a/coinbase_advanced_trader/strategies/fear_and_greed_strategies.py +++ /dev/null @@ -1,80 +0,0 @@ -import requests -from coinbase_advanced_trader.strategies.limit_order_strategies import fiat_limit_buy, fiat_limit_sell -from ..config import SIMPLE_SCHEDULE, PRO_SCHEDULE - - -def get_fear_and_greed_index(): - """ - Fetches the latest Fear and Greed Index (FGI) values from the API. - - Returns: - tuple: A tuple containing the FGI value and its classification. - """ - response = requests.get('https://api.alternative.me/fng/?limit=1') - data = response.json()['data'][0] - return int(data['value']), data['value_classification'] - - -def trade_based_on_fgi_simple(product_id, fiat_amount, schedule=SIMPLE_SCHEDULE): - """ - Executes a trade based on the Fear and Greed Index (FGI) using a simple strategy. - - Args: - product_id (str): The ID of the product to trade. - fiat_amount (float): The amount of fiat currency to trade. - schedule (list, optional): The trading schedule. Defaults to SIMPLE_SCHEDULE. - - Returns: - dict: The response from the trade execution. - """ - - fgi, classification = get_fear_and_greed_index() - - # Use the provided schedule or the default one - schedule = schedule or SIMPLE_SCHEDULE - - # Sort the schedule by threshold in ascending order - schedule.sort(key=lambda x: x['threshold']) - - # Get the lower and higher threshold values - lower_threshold = schedule[0]['threshold'] - higher_threshold = schedule[-1]['threshold'] - - for condition in schedule: - if fgi <= condition['threshold']: - fiat_amount *= condition['factor'] - if condition['action'] == 'buy': - return fiat_limit_buy(product_id, fiat_amount) - elif lower_threshold < fgi < higher_threshold: - response = fiat_limit_buy(product_id, fiat_amount) - else: - response = fiat_limit_sell(product_id, fiat_amount) - return {**response, 'Fear and Greed Index': fgi, 'classification': classification} - - -def trade_based_on_fgi_pro(product_id, fiat_amount, schedule=PRO_SCHEDULE): - """ - Executes a trade based on the Fear and Greed Index (FGI) using a professional strategy. - - Args: - product_id (str): The ID of the product to trade. - fiat_amount (float): The amount of fiat currency to trade. - schedule (list, optional): The trading schedule. Defaults to PRO_SCHEDULE. - - Returns: - dict: The response from the trade execution. - """ - - fgi, classification = get_fear_and_greed_index() - - # Use the provided schedule or the default one - schedule = schedule or PRO_SCHEDULE - - for condition in schedule: - if fgi <= condition['threshold']: - fiat_amount *= condition['factor'] - if condition['action'] == 'buy': - response = fiat_limit_buy(product_id, fiat_amount) - else: - response = fiat_limit_sell(product_id, fiat_amount) - return {**response, 'Fear and Greed Index': fgi, 'classification': classification} diff --git a/coinbase_advanced_trader/tests/__init__.py b/coinbase_advanced_trader/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/coinbase_advanced_trader/tests/test_fear_and_greed_strategies.py b/coinbase_advanced_trader/tests/test_fear_and_greed_strategies.py deleted file mode 100644 index 85a39de..0000000 --- a/coinbase_advanced_trader/tests/test_fear_and_greed_strategies.py +++ /dev/null @@ -1,33 +0,0 @@ -import unittest -from unittest.mock import patch -from coinbase_advanced_trader.strategies.fear_and_greed_strategies import get_fear_and_greed_index, trade_based_on_fgi_simple, trade_based_on_fgi_pro - - -class TestFearAndGreedStrategies(unittest.TestCase): - - @patch('coinbase_advanced_trader.strategies.fear_and_greed_strategies.requests.get') - def test_get_fear_and_greed_index(self, mock_get): - mock_get.return_value.json.return_value = { - 'data': [{'value': 50, 'value_classification': 'Neutral'}]} - self.assertEqual(get_fear_and_greed_index(), (50, 'Neutral')) - - @patch('coinbase_advanced_trader.strategies.fear_and_greed_strategies.get_fear_and_greed_index') - @patch('coinbase_advanced_trader.strategies.fear_and_greed_strategies.fiat_limit_buy') - def test_trade_based_on_fgi_simple(self, mock_buy, mock_index): - mock_index.return_value = (50, 'Neutral') - mock_buy.return_value = {'status': 'success'} - self.assertEqual(trade_based_on_fgi_simple( - 'BTC-USD', 100), {'status': 'success'}) - - @patch('coinbase_advanced_trader.strategies.fear_and_greed_strategies.get_fear_and_greed_index') - @patch('coinbase_advanced_trader.strategies.fear_and_greed_strategies.fiat_limit_buy') - @patch('coinbase_advanced_trader.strategies.fear_and_greed_strategies.fiat_limit_sell') - def test_trade_based_on_fgi_pro(self, mock_sell, mock_buy, mock_index): - mock_index.return_value = (80, 'Greed') - mock_sell.return_value = {'status': 'success'} - self.assertEqual(trade_based_on_fgi_pro( - 'BTC-USD', 100), {'status': 'success'}) - - -if __name__ == '__main__': - unittest.main() diff --git a/coinbase_advanced_trader/tests/test_limit_order_strategies.py b/coinbase_advanced_trader/tests/test_limit_order_strategies.py deleted file mode 100644 index 4e2f258..0000000 --- a/coinbase_advanced_trader/tests/test_limit_order_strategies.py +++ /dev/null @@ -1,43 +0,0 @@ -import unittest -from unittest.mock import patch -from decimal import Decimal -from coinbase_advanced_trader.strategies.limit_order_strategies import fiat_limit_buy, fiat_limit_sell - - -class TestLimitOrderStrategies(unittest.TestCase): - - @patch('coinbase_advanced_trader.strategies.limit_order_strategies.getProduct') - @patch('coinbase_advanced_trader.strategies.limit_order_strategies.get_spot_price') - @patch('coinbase_advanced_trader.strategies.limit_order_strategies.createOrder') - @patch('coinbase_advanced_trader.strategies.limit_order_strategies.generate_client_order_id') - def test_fiat_limit_buy(self, mock_generate_client_order_id, mock_createOrder, mock_get_spot_price, mock_getProduct): - # Mock the dependencies - mock_getProduct.return_value = { - 'quote_increment': '0.01', 'base_increment': '0.00000001'} - mock_get_spot_price.return_value = '28892.56' - mock_generate_client_order_id.return_value = 'example_order_id' - mock_createOrder.return_value = {'result': 'success'} - - # Test the function - result = fiat_limit_buy("BTC-USD", 200) - self.assertEqual(result['result'], 'success') - - @patch('coinbase_advanced_trader.strategies.limit_order_strategies.getProduct') - @patch('coinbase_advanced_trader.strategies.limit_order_strategies.get_spot_price') - @patch('coinbase_advanced_trader.strategies.limit_order_strategies.createOrder') - @patch('coinbase_advanced_trader.strategies.limit_order_strategies.generate_client_order_id') - def test_fiat_limit_sell(self, mock_generate_client_order_id, mock_createOrder, mock_get_spot_price, mock_getProduct): - # Mock the dependencies - mock_getProduct.return_value = { - 'quote_increment': '0.01', 'base_increment': '0.00000001'} - mock_get_spot_price.return_value = '28892.56' - mock_generate_client_order_id.return_value = 'example_order_id' - mock_createOrder.return_value = {'result': 'success'} - - # Test the function - result = fiat_limit_sell("BTC-USD", 200) - self.assertEqual(result['result'], 'success') - - -if __name__ == '__main__': - unittest.main() diff --git a/coinbase_advanced_trader/tests/test_market_order_strategies.py b/coinbase_advanced_trader/tests/test_market_order_strategies.py deleted file mode 100644 index 171b103..0000000 --- a/coinbase_advanced_trader/tests/test_market_order_strategies.py +++ /dev/null @@ -1,43 +0,0 @@ -import unittest -from unittest.mock import patch -from decimal import Decimal -from coinbase_advanced_trader.strategies.market_order_strategies import fiat_market_buy, fiat_market_sell - - -class TestMarketOrderStrategies(unittest.TestCase): - - @patch('coinbase_advanced_trader.strategies.market_order_strategies.getProduct') - @patch('coinbase_advanced_trader.strategies.market_order_strategies.get_spot_price') - @patch('coinbase_advanced_trader.strategies.market_order_strategies.createOrder') - @patch('coinbase_advanced_trader.strategies.market_order_strategies.generate_client_order_id') - def test_fiat_market_buy(self, mock_generate_client_order_id, mock_createOrder, mock_get_spot_price, mock_getProduct): - # Mock the dependencies - mock_getProduct.return_value = { - 'quote_increment': '0.01', 'base_increment': '0.00000001'} - mock_get_spot_price.return_value = '28892.56' - mock_generate_client_order_id.return_value = 'example_order_id' - mock_createOrder.return_value = {'result': 'success'} - - # Test the function - result = fiat_market_buy("BTC-USD", 200) - self.assertEqual(result['result'], 'success') - - @patch('coinbase_advanced_trader.strategies.market_order_strategies.getProduct') - @patch('coinbase_advanced_trader.strategies.market_order_strategies.get_spot_price') - @patch('coinbase_advanced_trader.strategies.market_order_strategies.createOrder') - @patch('coinbase_advanced_trader.strategies.market_order_strategies.generate_client_order_id') - def test_fiat_market_sell(self, mock_generate_client_order_id, mock_createOrder, mock_get_spot_price, mock_getProduct): - # Mock the dependencies - mock_getProduct.return_value = { - 'quote_increment': '0.01', 'base_increment': '0.00000001'} - mock_get_spot_price.return_value = '28892.56' - mock_generate_client_order_id.return_value = 'example_order_id' - mock_createOrder.return_value = {'result': 'success'} - - # Test the function - result = fiat_market_sell("BTC-USD", 200) - self.assertEqual(result['result'], 'success') - - -if __name__ == '__main__': - unittest.main() diff --git a/coinbase_advanced_trader/tests/test_utils.py b/coinbase_advanced_trader/tests/test_utils.py deleted file mode 100644 index 2df9b69..0000000 --- a/coinbase_advanced_trader/tests/test_utils.py +++ /dev/null @@ -1,42 +0,0 @@ -import unittest -from unittest.mock import patch, MagicMock -from coinbase_advanced_trader.strategies.utils import get_spot_price - - -class TestGetSpotPrice(unittest.TestCase): - @patch('coinbase_advanced_trader.strategies.utils.client.getProduct') - def test_get_spot_price_success(self, mock_get_product): - # Mock the getProduct function to return a successful response - mock_get_product.return_value = {'price': '50000'} - - # Test the function with a product_id - result = get_spot_price('BTC-USD') - - # Assert that the function returns the correct spot price - self.assertEqual(result, 50000.0) - - @patch('coinbase_advanced_trader.strategies.utils.client.getProduct') - def test_get_spot_price_failure(self, mock_get_product): - # Mock the getProduct function to return a response without a 'price' field - mock_get_product.return_value = {} - - # Test the function with a product_id - result = get_spot_price('BTC-USD') - - # Assert that the function returns None when the 'price' field is missing - self.assertIsNone(result) - - @patch('coinbase_advanced_trader.strategies.utils.client.getProduct') - def test_get_spot_price_exception(self, mock_get_product): - # Mock the getProduct function to raise an exception - mock_get_product.side_effect = Exception('Test exception') - - # Test the function with a product_id - result = get_spot_price('BTC-USD') - - # Assert that the function returns None when an exception is raised - self.assertIsNone(result) - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_coinbase_client.py b/tests/test_coinbase_client.py deleted file mode 100644 index 6c62fb4..0000000 --- a/tests/test_coinbase_client.py +++ /dev/null @@ -1,572 +0,0 @@ -import unittest -from unittest.mock import patch -from datetime import datetime -from coinbase_advanced_trader.coinbase_client import ( - Method, - listAccounts, - getAccount, - createOrder, - cancelOrders, - listOrders, - listFills, - getOrder, - listProducts, - getProduct, - getProductCandles, - getMarketTrades, - getTransactionsSummary, -) - - -class TestCoinbaseClient(unittest.TestCase): - @patch('coinbase_advanced_trader.coinbase_client.cb_auth') - def test_list_accounts(self, mock_cb_auth): - # Mock the response from the API - mock_cb_auth.return_value = { - "accounts": [{ - "uuid": "8bfc20d7-f7c6-4422-bf07-8243ca4169fe", - "name": "BTC Wallet", - "currency": "BTC", - "available_balance": { - "value": "1.23", - "currency": "BTC" - }, - "default": False, - "active": True, - "created_at": "2021-05-31T09:59:59Z", - "updated_at": "2021-05-31T09:59:59Z", - "deleted_at": "2021-05-31T09:59:59Z", - "type": "ACCOUNT_TYPE_UNSPECIFIED", - "ready": True, - "hold": { - "value": "1.23", - "currency": "BTC" - } - }], - "has_next": True, - "cursor": "789100", - "size": 1 - } - - # Call the function with sample input - result = listAccounts(limit=5, cursor=None) - - # Assert the expected output - self.assertIsNotNone(result) - self.assertIsInstance(result, dict) - self.assertIn('accounts', result) - self.assertIsInstance(result['accounts'], list) - self.assertEqual(len(result['accounts']), 1) - account = result['accounts'][0] - self.assertEqual( - account['uuid'], "8bfc20d7-f7c6-4422-bf07-8243ca4169fe") - self.assertEqual(account['name'], "BTC Wallet") - self.assertEqual(account['currency'], "BTC") - self.assertEqual(account['available_balance']['value'], "1.23") - self.assertEqual(account['available_balance']['currency'], "BTC") - self.assertFalse(account['default']) - self.assertTrue(account['active']) - self.assertEqual(account['created_at'], "2021-05-31T09:59:59Z") - self.assertEqual(account['updated_at'], "2021-05-31T09:59:59Z") - self.assertEqual(account['deleted_at'], "2021-05-31T09:59:59Z") - self.assertEqual(account['type'], "ACCOUNT_TYPE_UNSPECIFIED") - self.assertTrue(account['ready']) - self.assertEqual(account['hold']['value'], "1.23") - self.assertEqual(account['hold']['currency'], "BTC") - self.assertTrue(result['has_next']) - self.assertEqual(result['cursor'], "789100") - self.assertEqual(result['size'], 1) - - @patch('coinbase_advanced_trader.coinbase_client.cb_auth') - def test_get_account(self, mock_cb_auth): - # Mock the response from the API - mock_cb_auth.return_value = { - "account": { - "uuid": "8bfc20d7-f7c6-4422-bf07-8243ca4169fe", - "name": "BTC Wallet", - "currency": "BTC", - "available_balance": { - "value": "1.23", - "currency": "BTC" - }, - "default": False, - "active": True, - "created_at": "2021-05-31T09:59:59Z", - "updated_at": "2021-05-31T09:59:59Z", - "deleted_at": "2021-05-31T09:59:59Z", - "type": "ACCOUNT_TYPE_UNSPECIFIED", - "ready": True, - "hold": { - "value": "1.23", - "currency": "BTC" - } - } - } - - # Call the function with sample input - account_uuid = "8bfc20d7-f7c6-4422-bf07-8243ca4169fe" - result = getAccount(account_uuid) - - # Assert the expected output - self.assertIsNotNone(result) - self.assertIsInstance(result, dict) - self.assertIn('account', result) - account = result['account'] - self.assertEqual( - account['uuid'], "8bfc20d7-f7c6-4422-bf07-8243ca4169fe") - self.assertEqual(account['name'], "BTC Wallet") - self.assertEqual(account['currency'], "BTC") - self.assertEqual(account['available_balance']['value'], "1.23") - self.assertEqual(account['available_balance']['currency'], "BTC") - self.assertFalse(account['default']) - self.assertTrue(account['active']) - self.assertEqual(account['created_at'], "2021-05-31T09:59:59Z") - self.assertEqual(account['updated_at'], "2021-05-31T09:59:59Z") - self.assertEqual(account['deleted_at'], "2021-05-31T09:59:59Z") - self.assertEqual(account['type'], "ACCOUNT_TYPE_UNSPECIFIED") - self.assertTrue(account['ready']) - self.assertEqual(account['hold']['value'], "1.23") - self.assertEqual(account['hold']['currency'], "BTC") - - @patch('coinbase_advanced_trader.coinbase_client.cb_auth') - def test_createOrder(self, mock_cb_auth): - # Mock the cb_auth function to return a sample response - mock_cb_auth.return_value = {'result': 'success'} - - # Test the createOrder function - client_order_id = 'example_order_id' - product_id = 'BTC-USD' - side = 'buy' - order_type = 'limit_limit_gtc' - order_configuration = { - 'limit_price': '30000.00', - 'base_size': '0.01', - 'post_only': True - } - - result = createOrder( - client_order_id=client_order_id, - product_id=product_id, - side=side, - order_type=order_type, - order_configuration=order_configuration - ) - - # Check that the cb_auth function was called with the correct arguments - expected_payload = { - 'client_order_id': client_order_id, - 'product_id': product_id, - 'side': side, - 'order_configuration': { - order_type: order_configuration - } - } - mock_cb_auth.assert_called_with(Method.POST.value, '/api/v3/brokerage/orders', expected_payload) - - # Check that the createOrder function returns the response from cb_auth - self.assertEqual(result, {'result': 'success'}) - - @patch('coinbase_advanced_trader.coinbase_client.cb_auth') - def test_cancel_orders(self, mock_cb_auth): - # Mock the response from the API - mock_cb_auth.return_value = { - "results": { - "success": True, - "failure_reason": "UNKNOWN_CANCEL_FAILURE_REASON", - "order_id": "0000-00000" - } - } - - # Call the function with sample input - order_ids = ["0000-00000"] - result = cancelOrders(order_ids) - - # Assert the expected output - self.assertIsNotNone(result) - self.assertIsInstance(result, dict) - self.assertIn('results', result) - results = result['results'] - self.assertTrue(results['success']) - self.assertEqual(results['failure_reason'], - "UNKNOWN_CANCEL_FAILURE_REASON") - self.assertEqual(results['order_id'], "0000-00000") - - @patch('coinbase_advanced_trader.coinbase_client.cb_auth') - def test_list_orders(self, mock_cb_auth): - # Mock the response from the API - mock_cb_auth.return_value = { - "orders": { - "order_id": "0000-000000-000000", - "product_id": "BTC-USD", - "user_id": "2222-000000-000000", - "order_configuration": { - # Sample order configuration data - }, - "side": "UNKNOWN_ORDER_SIDE", - "client_order_id": "11111-000000-000000", - "status": "OPEN", - "time_in_force": "UNKNOWN_TIME_IN_FORCE", - "created_time": "2021-05-31T09:59:59Z", - "completion_percentage": "50", - "filled_size": "0.001", - "average_filled_price": "50", - "fee": "string", - "number_of_fills": "2", - "filled_value": "10000", - "pending_cancel": True, - "size_in_quote": False, - "total_fees": "5.00", - "size_inclusive_of_fees": False, - "total_value_after_fees": "string", - "trigger_status": "UNKNOWN_TRIGGER_STATUS", - "order_type": "UNKNOWN_ORDER_TYPE", - "reject_reason": "REJECT_REASON_UNSPECIFIED", - "settled": "boolean", - "product_type": "SPOT", - "reject_message": "string", - "cancel_message": "string", - "order_placement_source": "RETAIL_ADVANCED" - }, - "sequence": "string", - "has_next": True, - "cursor": "789100" - } - - # Call the function with sample input - result = listOrders() - - # Assert the expected output - self.assertIsNotNone(result) - self.assertIsInstance(result, dict) - self.assertIn('orders', result) - self.assertIn('sequence', result) - self.assertIn('has_next', result) - self.assertIn('cursor', result) - self.assertTrue(result['has_next']) - self.assertEqual(result['cursor'], '789100') - - @patch('coinbase_advanced_trader.coinbase_client.cb_auth') - def test_list_fills(self, mock_cb_auth): - # Mock the response from the API - mock_cb_auth.return_value = { - "fills": { - "entry_id": "22222-2222222-22222222", - "trade_id": "1111-11111-111111", - "order_id": "0000-000000-000000", - "trade_time": "2021-05-31T09:59:59Z", - "trade_type": "FILL", - "price": "10000.00", - "size": "0.001", - "commission": "1.25", - "product_id": "BTC-USD", - "sequence_timestamp": "2021-05-31T09:58:59Z", - "liquidity_indicator": "UNKNOWN_LIQUIDITY_INDICATOR", - "size_in_quote": False, - "user_id": "3333-333333-3333333", - "side": "UNKNOWN_ORDER_SIDE" - }, - "cursor": "789100" - } - - # Call the function with sample input - result = listFills(order_id="0000-000000-000000", product_id="BTC-USD") - - # Assert the expected output - self.assertIsNotNone(result) - self.assertIsInstance(result, dict) - self.assertIn('fills', result) - - fill = result['fills'] - self.assertEqual(fill['entry_id'], "22222-2222222-22222222") - self.assertEqual(fill['trade_id'], "1111-11111-111111") - self.assertEqual(fill['order_id'], "0000-000000-000000") - self.assertEqual(fill['trade_time'], "2021-05-31T09:59:59Z") - self.assertEqual(fill['trade_type'], "FILL") - self.assertEqual(fill['price'], "10000.00") - self.assertEqual(fill['size'], "0.001") - self.assertEqual(fill['commission'], "1.25") - self.assertEqual(fill['product_id'], "BTC-USD") - self.assertEqual(fill['sequence_timestamp'], "2021-05-31T09:58:59Z") - self.assertEqual(fill['liquidity_indicator'], - "UNKNOWN_LIQUIDITY_INDICATOR") - self.assertFalse(fill['size_in_quote']) - self.assertEqual(fill['user_id'], "3333-333333-3333333") - self.assertEqual(fill['side'], "UNKNOWN_ORDER_SIDE") - - self.assertEqual(result['cursor'], "789100") - - @patch('coinbase_advanced_trader.coinbase_client.cb_auth') - def test_get_order(self, mock_cb_auth): - # Mock the response from the API - mock_cb_auth.return_value = { - "order": { - "order_id": "0000-000000-000000", - "product_id": "BTC-USD", - "user_id": "2222-000000-000000", - "order_configuration": { - # Sample order configuration data - }, - "side": "UNKNOWN_ORDER_SIDE", - "client_order_id": "11111-000000-000000", - "status": "OPEN", - "time_in_force": "UNKNOWN_TIME_IN_FORCE", - "created_time": "2021-05-31T09:59:59Z", - "completion_percentage": "50", - "filled_size": "0.001", - "average_filled_price": "50", - "fee": "string", - "number_of_fills": "2", - "filled_value": "10000", - "pending_cancel": True, - "size_in_quote": False, - "total_fees": "5.00", - "size_inclusive_of_fees": False, - "total_value_after_fees": "string", - "trigger_status": "UNKNOWN_TRIGGER_STATUS", - "order_type": "UNKNOWN_ORDER_TYPE", - "reject_reason": "REJECT_REASON_UNSPECIFIED", - "settled": "boolean", - "product_type": "SPOT", - "reject_message": "string", - "cancel_message": "string", - "order_placement_source": "RETAIL_ADVANCED" - } - } - - # Call the function with sample input - order_id = "0000-000000-000000" - result = getOrder(order_id) - - # Assert the expected output - self.assertIsNotNone(result) - self.assertIsInstance(result, dict) - self.assertIn('order', result) - self.assertEqual(result['order']['order_id'], order_id) - self.assertEqual(result['order']['product_id'], 'BTC-USD') - self.assertEqual(result['order']['status'], 'OPEN') - - @patch('coinbase_advanced_trader.coinbase_client.cb_auth') - def test_list_products(self, mock_cb_auth): - # Mock the response from the API - mock_cb_auth.return_value = { - "products": { - "product_id": "BTC-USD", - "price": "140.21", - "price_percentage_change_24h": "9.43%", - "volume_24h": "1908432", - "volume_percentage_change_24h": "9.43%", - "base_increment": "0.00000001", - "quote_increment": "0.00000001", - "quote_min_size": "0.00000001", - "quote_max_size": "1000", - "base_min_size": "0.00000001", - "base_max_size": "1000", - "base_name": "Bitcoin", - "quote_name": "US Dollar", - "watched": True, - "is_disabled": False, - "new": True, - "status": "string", - "cancel_only": True, - "limit_only": True, - "post_only": True, - "trading_disabled": False, - "auction_mode": True, - "product_type": "SPOT", - "quote_currency_id": "USD", - "base_currency_id": "BTC", - "mid_market_price": "140.22", - "base_display_symbol": "BTC", - "quote_display_symbol": "USD" - }, - "num_products": 100 - } - - # Call the function with sample input - limit = 10 - offset = 0 - product_type = 'SPOT' - result = listProducts(limit=limit, offset=offset, - product_type=product_type) - - # Assert the expected output - self.assertIsNotNone(result) - self.assertIsInstance(result, dict) - self.assertIn('products', result) - self.assertIn('num_products', result) - self.assertEqual(result['products']['product_id'], 'BTC-USD') - self.assertEqual(result['num_products'], 100) - - @patch('coinbase_advanced_trader.coinbase_client.cb_auth') - def test_get_product(self, mock_cb_auth): - # Mock the response from the API - mock_cb_auth.return_value = { - "product_id": "BTC-USD", - "price": "140.21", - "price_percentage_change_24h": "9.43%", - "volume_24h": "1908432", - "volume_percentage_change_24h": "9.43%", - "base_increment": "0.00000001", - "quote_increment": "0.00000001", - "quote_min_size": "0.00000001", - "quote_max_size": "1000", - "base_min_size": "0.00000001", - "base_max_size": "1000", - "base_name": "Bitcoin", - "quote_name": "US Dollar", - "watched": True, - "is_disabled": False, - "new": True, - "status": "string", - "cancel_only": True, - "limit_only": True, - "post_only": True, - "trading_disabled": False, - "auction_mode": True, - "product_type": "SPOT", - "quote_currency_id": "USD", - "base_currency_id": "BTC", - "mid_market_price": "140.22", - "base_display_symbol": "BTC", - "quote_display_symbol": "USD" - } - - # Call the function with sample input - product_id = 'BTC-USD' - result = getProduct(product_id=product_id) - - # Assert the expected output - self.assertIsNotNone(result) - self.assertIsInstance(result, dict) - self.assertEqual(result['product_id'], 'BTC-USD') - self.assertEqual(result['price'], '140.21') - self.assertEqual(result['base_name'], 'Bitcoin') - self.assertEqual(result['quote_name'], 'US Dollar') - - @patch('coinbase_advanced_trader.coinbase_client.cb_auth') - def test_get_product_candles(self, mock_cb_auth): - # Mock the response from the API - mock_cb_auth.return_value = { - "candles": { - "start": "1639508050", - "low": "140.21", - "high": "140.21", - "open": "140.21", - "close": "140.21", - "volume": "56437345" - } - } - - # Call the function with sample input - product_id = "BTC-USD" - start = "1639508050" - end = "1639511650" - granularity = "3600" - - result = getProductCandles(product_id, start, end, granularity) - - # Assert the expected output - self.assertIsNotNone(result) - self.assertIsInstance(result, dict) - self.assertIn("candles", result) - self.assertEqual(result["candles"]["start"], start) - self.assertEqual(result["candles"]["low"], "140.21") - self.assertEqual(result["candles"]["high"], "140.21") - self.assertEqual(result["candles"]["open"], "140.21") - self.assertEqual(result["candles"]["close"], "140.21") - self.assertEqual(result["candles"]["volume"], "56437345") - - @patch('coinbase_advanced_trader.coinbase_client.cb_auth') - def test_get_market_trades(self, mock_cb_auth): - # Mock the response from the API - mock_cb_auth.return_value = { - "trades": { - "trade_id": "34b080bf-fcfd-445a-832b-46b5ddc65601", - "product_id": "BTC-USD", - "price": "140.91", - "size": "4", - "time": "2021-05-31T09:59:59Z", - "side": "UNKNOWN_ORDER_SIDE", - "bid": "291.13", - "ask": "292.40" - }, - "best_bid": "291.13", - "best_ask": "292.40" - } - - # Call the function with sample input - product_id = "BTC-USD" - limit = 10 - result = getMarketTrades(product_id, limit) - - # Assert the expected output - self.assertIsNotNone(result) - self.assertIsInstance(result, dict) - self.assertIn("trades", result) - self.assertEqual(result["trades"]["trade_id"], - "34b080bf-fcfd-445a-832b-46b5ddc65601") - self.assertEqual(result["trades"]["product_id"], product_id) - self.assertEqual(result["trades"]["price"], "140.91") - self.assertEqual(result["trades"]["size"], "4") - self.assertEqual(result["trades"]["side"], "UNKNOWN_ORDER_SIDE") - self.assertEqual(result["trades"]["bid"], "291.13") - self.assertEqual(result["trades"]["ask"], "292.40") - self.assertEqual(result["best_bid"], "291.13") - self.assertEqual(result["best_ask"], "292.40") - - @patch('coinbase_advanced_trader.coinbase_client.cb_auth') - def test_get_transactions_summary(self, mock_cb_auth): - # Mock the response from the API - mock_cb_auth.return_value = { - "total_volume": 1000, - "total_fees": 25, - "fee_tier": { - "pricing_tier": "<$10k", - "usd_from": "0", - "usd_to": "10,000", - "taker_fee_rate": "0.0010", - "maker_fee_rate": "0.0020" - }, - "margin_rate": { - "value": "string" - }, - "goods_and_services_tax": { - "rate": "string", - "type": "INCLUSIVE" - }, - "advanced_trade_only_volume": 1000, - "advanced_trade_only_fees": 25, - "coinbase_pro_volume": 1000, - "coinbase_pro_fees": 25 - } - # Call the function with sample input - start_date = datetime.strptime( - "2021-01-01T00:00:00Z", '%Y-%m-%dT%H:%M:%SZ') - end_date = datetime.strptime( - "2021-01-31T23:59:59Z", '%Y-%m-%dT%H:%M:%SZ') - user_native_currency = "USD" - product_type = "SPOT" - result = getTransactionsSummary( - start_date, end_date, user_native_currency, product_type) - - # Assert the expected output - self.assertIsNotNone(result) - self.assertIsInstance(result, dict) - self.assertEqual(result["total_volume"], 1000) - self.assertEqual(result["total_fees"], 25) - self.assertEqual(result["fee_tier"]["pricing_tier"], "<$10k") - self.assertEqual(result["fee_tier"]["usd_from"], "0") - self.assertEqual(result["fee_tier"]["usd_to"], "10,000") - self.assertEqual(result["fee_tier"]["taker_fee_rate"], "0.0010") - self.assertEqual(result["fee_tier"]["maker_fee_rate"], "0.0020") - self.assertEqual(result["margin_rate"]["value"], "string") - self.assertEqual(result["goods_and_services_tax"]["rate"], "string") - self.assertEqual(result["goods_and_services_tax"]["type"], "INCLUSIVE") - self.assertEqual(result["advanced_trade_only_volume"], 1000) - self.assertEqual(result["advanced_trade_only_fees"], 25) - self.assertEqual(result["coinbase_pro_volume"], 1000) - self.assertEqual(result["coinbase_pro_fees"], 25) - - -if __name__ == '__main__': - unittest.main() From b516ad655798624b0dfd372d2050227434bf3886 Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Mon, 24 Jun 2024 23:09:13 -0500 Subject: [PATCH 08/23] Faster limit order fills --- coinbase_advanced_trader/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coinbase_advanced_trader/config.py b/coinbase_advanced_trader/config.py index e20c521..1428064 100644 --- a/coinbase_advanced_trader/config.py +++ b/coinbase_advanced_trader/config.py @@ -1,5 +1,5 @@ # Default price multipliers for limit orders -BUY_PRICE_MULTIPLIER = 0.995 +BUY_PRICE_MULTIPLIER = 0.9995 SELL_PRICE_MULTIPLIER = 1.005 # Default schedule for the trade_based_on_fgi_simple function From e6c97826ac3391dbeb5d799d0418c0af19f05cab Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Mon, 24 Jun 2024 23:12:04 -0500 Subject: [PATCH 09/23] Fear and greed + fee update --- .../EnhancedRESTClient.py | 81 ++++++++++++++++++- 1 file changed, 78 insertions(+), 3 deletions(-) diff --git a/coinbase_advanced_trader/EnhancedRESTClient.py b/coinbase_advanced_trader/EnhancedRESTClient.py index ceefd2d..4166138 100644 --- a/coinbase_advanced_trader/EnhancedRESTClient.py +++ b/coinbase_advanced_trader/EnhancedRESTClient.py @@ -1,9 +1,10 @@ import uuid +import requests from typing import Dict, Any from decimal import Decimal, ROUND_HALF_UP from enum import Enum from coinbase.rest import RESTClient -from coinbase_advanced_trader.config import BUY_PRICE_MULTIPLIER, SELL_PRICE_MULTIPLIER +from coinbase_advanced_trader.config import BUY_PRICE_MULTIPLIER, SELL_PRICE_MULTIPLIER, SIMPLE_SCHEDULE, PRO_SCHEDULE from coinbase_advanced_trader.logger import logger class Side(Enum): @@ -13,7 +14,7 @@ class Side(Enum): class EnhancedRESTClient(RESTClient): def __init__(self, api_key: str, api_secret: str, **kwargs): super().__init__(api_key=api_key, api_secret=api_secret, **kwargs) - self.MAKER_FEE_RATE = Decimal('0.004') + self.MAKER_FEE_RATE = Decimal('0.006') def _generate_client_order_id(self) -> str: return str(uuid.uuid4()) @@ -161,4 +162,78 @@ def _log_order_result(self, order: Dict[str, Any], product_id: str, amount: Any, preview_failure_reason = order.get('error_response', {}).get('preview_failure_reason', 'Unknown') logger.error(f"Failed to place a {order_type} {side_str} order. Reason: {failure_reason}. Preview failure reason: {preview_failure_reason}") - logger.debug(f"Coinbase response: {order}") \ No newline at end of file + logger.debug(f"Coinbase response: {order}") + + def get_fear_and_greed_index(self): + """ + Fetches the latest Fear and Greed Index (FGI) values from the API. + + Returns: + tuple: A tuple containing the FGI value and its classification. + """ + response = requests.get('https://api.alternative.me/fng/?limit=1') + data = response.json()['data'][0] + return int(data['value']), data['value_classification'] + + def trade_based_on_fgi_simple(self, product_id, fiat_amount, schedule=SIMPLE_SCHEDULE): + """ + Executes a trade based on the Fear and Greed Index (FGI) using a simple strategy. + + Args: + product_id (str): The ID of the product to trade. + fiat_amount (float): The amount of fiat currency to trade. + schedule (list, optional): The trading schedule. Defaults to SIMPLE_SCHEDULE. + + Returns: + dict: The response from the trade execution. + """ + + fgi, classification = self.get_fear_and_greed_index() + + # Use the provided schedule or the default one + schedule = schedule or SIMPLE_SCHEDULE + + # Sort the schedule by threshold in ascending order + schedule.sort(key=lambda x: x['threshold']) + + # Get the lower and higher threshold values + lower_threshold = schedule[0]['threshold'] + higher_threshold = schedule[-1]['threshold'] + + for condition in schedule: + if fgi <= condition['threshold']: + fiat_amount = float(fiat_amount) * condition['factor'] + if condition['action'] == 'buy': + return self.fiat_limit_buy(product_id, str(fiat_amount)) + elif lower_threshold < fgi < higher_threshold: + response = self.fiat_limit_buy(product_id, str(fiat_amount)) + else: + response = self.fiat_limit_sell(product_id, str(fiat_amount)) + return {**response, 'Fear and Greed Index': fgi, 'classification': classification} + + def trade_based_on_fgi_pro(self, product_id, fiat_amount, schedule=PRO_SCHEDULE): + """ + Executes a trade based on the Fear and Greed Index (FGI) using a professional strategy. + + Args: + product_id (str): The ID of the product to trade. + fiat_amount (float): The amount of fiat currency to trade. + schedule (list, optional): The trading schedule. Defaults to PRO_SCHEDULE. + + Returns: + dict: The response from the trade execution. + """ + + fgi, classification = self.get_fear_and_greed_index() + + # Use the provided schedule or the default one + schedule = schedule or PRO_SCHEDULE + + for condition in schedule: + if fgi <= condition['threshold']: + fiat_amount = float(fiat_amount) * condition['factor'] + if condition['action'] == 'buy': + response = self.fiat_limit_buy(product_id, str(fiat_amount)) + else: + response = self.fiat_limit_sell(product_id, str(fiat_amount)) + return {**response, 'Fear and Greed Index': fgi, 'classification': classification} From b78f1c837bd288212f9960129c45e2d379a22df9 Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Sat, 29 Jun 2024 12:59:37 -0500 Subject: [PATCH 10/23] fix issues in enhanced rest client --- coinbase_advanced_trader/EnhancedRESTClient.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/coinbase_advanced_trader/EnhancedRESTClient.py b/coinbase_advanced_trader/EnhancedRESTClient.py index 4166138..f0c9e9c 100644 --- a/coinbase_advanced_trader/EnhancedRESTClient.py +++ b/coinbase_advanced_trader/EnhancedRESTClient.py @@ -59,7 +59,7 @@ def fiat_market_buy(self, product_id: str, fiat_amount: str) -> Dict[str, Any]: dict: The order response from Coinbase. """ order = self.market_order_buy(self._generate_client_order_id(), product_id, fiat_amount) - self._log_order_result(order, product_id, fiat_amount, Side.BUY) + self._log_order_result(order, product_id, fiat_amount, side=Side.BUY) return order def fiat_market_sell(self, product_id: str, fiat_amount: str) -> Dict[str, Any]: @@ -78,7 +78,7 @@ def fiat_market_sell(self, product_id: str, fiat_amount: str) -> Dict[str, Any]: base_size = self._calculate_base_size(Decimal(fiat_amount), spot_price, base_increment) order = self.market_order_sell(self._generate_client_order_id(), product_id, str(base_size)) - self._log_order_result(order, product_id, fiat_amount, Side.SELL) + self._log_order_result(order, product_id, fiat_amount, side=Side.SELL) return order def fiat_limit_buy(self, product_id: str, fiat_amount: str, price_multiplier: float = BUY_PRICE_MULTIPLIER) -> Dict[str, Any]: @@ -138,7 +138,6 @@ def _place_limit_order(self, product_id: str, fiat_amount: str, price_multiplier def _log_order_result(self, order: Dict[str, Any], product_id: str, amount: Any, price: Any = None, side: Side = None) -> None: """ Log the result of an order. - Args: order (Dict[str, Any]): The order response from Coinbase. product_id (str): The ID of the product. @@ -154,14 +153,13 @@ def _log_order_result(self, order: Dict[str, Any], product_id: str, amount: Any, if price: total_amount = Decimal(amount) * Decimal(price) logger.info(f"Successfully placed a {order_type} {side_str} order for {amount} {base_currency} " - f"(${total_amount:.2f}) at a price of {price} {quote_currency}.") + f"(${total_amount:.2f}) at a price of {price} {quote_currency}.") else: logger.info(f"Successfully placed a {order_type} {side_str} order for {amount} {quote_currency} of {base_currency}.") else: failure_reason = order.get('failure_reason', 'Unknown') preview_failure_reason = order.get('error_response', {}).get('preview_failure_reason', 'Unknown') logger.error(f"Failed to place a {order_type} {side_str} order. Reason: {failure_reason}. Preview failure reason: {preview_failure_reason}") - logger.debug(f"Coinbase response: {order}") def get_fear_and_greed_index(self): From c98bc886e385db27b5946b5bbc6134b07fffc265 Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Sat, 29 Jun 2024 16:46:01 -0500 Subject: [PATCH 11/23] Refactored structure updated fear and greed updated enhanced rest client --- .../EnhancedRESTClient.py | 237 ------------------ coinbase_advanced_trader/config.py | 19 +- .../enhanced_rest_client.py | 117 +++++++++ coinbase_advanced_trader/logger.py | 5 +- coinbase_advanced_trader/models/__init__.py | 4 + coinbase_advanced_trader/models/order.py | 43 ++++ coinbase_advanced_trader/models/product.py | 21 ++ coinbase_advanced_trader/services/__init__.py | 5 + .../services/order_service.py | 142 +++++++++++ .../services/price_service.py | 31 +++ .../services/trading_strategy_service.py | 46 ++++ coinbase_advanced_trader/tests/__init__.py | 0 coinbase_advanced_trader/tests/test_client.py | 0 .../tests/test_order_service.py | 0 .../tests/test_price_service.py | 0 coinbase_advanced_trader/utils/__init__.py | 3 + coinbase_advanced_trader/utils/helpers.py | 26 ++ 17 files changed, 451 insertions(+), 248 deletions(-) delete mode 100644 coinbase_advanced_trader/EnhancedRESTClient.py create mode 100644 coinbase_advanced_trader/enhanced_rest_client.py create mode 100644 coinbase_advanced_trader/models/__init__.py create mode 100644 coinbase_advanced_trader/models/order.py create mode 100644 coinbase_advanced_trader/models/product.py create mode 100644 coinbase_advanced_trader/services/__init__.py create mode 100644 coinbase_advanced_trader/services/order_service.py create mode 100644 coinbase_advanced_trader/services/price_service.py create mode 100644 coinbase_advanced_trader/services/trading_strategy_service.py create mode 100644 coinbase_advanced_trader/tests/__init__.py create mode 100644 coinbase_advanced_trader/tests/test_client.py create mode 100644 coinbase_advanced_trader/tests/test_order_service.py create mode 100644 coinbase_advanced_trader/tests/test_price_service.py create mode 100644 coinbase_advanced_trader/utils/__init__.py create mode 100644 coinbase_advanced_trader/utils/helpers.py diff --git a/coinbase_advanced_trader/EnhancedRESTClient.py b/coinbase_advanced_trader/EnhancedRESTClient.py deleted file mode 100644 index f0c9e9c..0000000 --- a/coinbase_advanced_trader/EnhancedRESTClient.py +++ /dev/null @@ -1,237 +0,0 @@ -import uuid -import requests -from typing import Dict, Any -from decimal import Decimal, ROUND_HALF_UP -from enum import Enum -from coinbase.rest import RESTClient -from coinbase_advanced_trader.config import BUY_PRICE_MULTIPLIER, SELL_PRICE_MULTIPLIER, SIMPLE_SCHEDULE, PRO_SCHEDULE -from coinbase_advanced_trader.logger import logger - -class Side(Enum): - BUY = 1 - SELL = 0 - -class EnhancedRESTClient(RESTClient): - def __init__(self, api_key: str, api_secret: str, **kwargs): - super().__init__(api_key=api_key, api_secret=api_secret, **kwargs) - self.MAKER_FEE_RATE = Decimal('0.006') - - def _generate_client_order_id(self) -> str: - return str(uuid.uuid4()) - - def get_spot_price(self, product_id: str) -> Decimal: - """ - Fetches the current spot price of a specified product. - - Args: - product_id (str): The ID of the product (e.g., "BTC-USD"). - - Returns: - Decimal: The spot price as a Decimal, or None if an error occurs. - """ - try: - response = self.get_product(product_id) - quote_increment = Decimal(response['quote_increment']) - - if 'price' in response: - price = Decimal(response['price']) - return price.quantize(quote_increment) - else: - logger.error(f"'price' field missing in response for {product_id}") - return None - - except Exception as e: - logger.error(f"Error fetching spot price for {product_id}: {e}") - return None - - def _calculate_base_size(self, fiat_amount: Decimal, spot_price: Decimal, base_increment: Decimal) -> Decimal: - return ((fiat_amount / spot_price) / base_increment).quantize(Decimal('1'), rounding=ROUND_HALF_UP) * base_increment - - def fiat_market_buy(self, product_id: str, fiat_amount: str) -> Dict[str, Any]: - """ - Place a market buy order for a specified fiat amount. - - Args: - product_id (str): The ID of the product to buy (e.g., "BTC-USDC"). - fiat_amount (str): The amount of fiat currency to spend. - - Returns: - dict: The order response from Coinbase. - """ - order = self.market_order_buy(self._generate_client_order_id(), product_id, fiat_amount) - self._log_order_result(order, product_id, fiat_amount, side=Side.BUY) - return order - - def fiat_market_sell(self, product_id: str, fiat_amount: str) -> Dict[str, Any]: - """ - Place a market sell order for a specified fiat amount. - - Args: - product_id (str): The ID of the product to sell (e.g., "BTC-USDC"). - fiat_amount (str): The amount of fiat currency to receive. - - Returns: - dict: The order response from Coinbase. - """ - spot_price = self.get_spot_price(product_id) - base_increment = Decimal(self.get_product(product_id)['base_increment']) - base_size = self._calculate_base_size(Decimal(fiat_amount), spot_price, base_increment) - - order = self.market_order_sell(self._generate_client_order_id(), product_id, str(base_size)) - self._log_order_result(order, product_id, fiat_amount, side=Side.SELL) - return order - - def fiat_limit_buy(self, product_id: str, fiat_amount: str, price_multiplier: float = BUY_PRICE_MULTIPLIER) -> Dict[str, Any]: - """ - Places a limit buy order. - - Args: - product_id (str): The ID of the product to buy (e.g., "BTC-USD"). - fiat_amount (str): The amount in fiat currency to spend on buying. - price_multiplier (float, optional): Multiplier to apply to the current spot price to get the limit price. - Defaults to BUY_PRICE_MULTIPLIER. - - Returns: - Dict[str, Any]: The response containing order details. - """ - return self._place_limit_order(product_id, fiat_amount, price_multiplier, Side.BUY) - - def fiat_limit_sell(self, product_id: str, fiat_amount: str, price_multiplier: float = SELL_PRICE_MULTIPLIER) -> Dict[str, Any]: - """ - Places a limit sell order. - - Args: - product_id (str): The ID of the product to sell (e.g., "BTC-USD"). - fiat_amount (str): The amount in USD or other fiat to receive from selling (e.g., "200"). - price_multiplier (float, optional): Multiplier to apply to the current spot price to get the limit price. - Defaults to SELL_PRICE_MULTIPLIER. - - Returns: - Dict[str, Any]: The response of the order details. - """ - return self._place_limit_order(product_id, fiat_amount, price_multiplier, Side.SELL) - - def _place_limit_order(self, product_id: str, fiat_amount: str, price_multiplier: float, side: Side) -> Dict[str, Any]: - product_details = self.get_product(product_id) - quote_increment = Decimal(product_details['quote_increment']) - base_increment = Decimal(product_details['base_increment']) - - spot_price = self.get_spot_price(product_id) - limit_price = (Decimal(spot_price) * Decimal(price_multiplier)).quantize(quote_increment) - - fiat_amount_decimal = Decimal(fiat_amount) - effective_fiat_amount = fiat_amount_decimal * (1 - self.MAKER_FEE_RATE) if side == Side.BUY else fiat_amount_decimal / (1 - self.MAKER_FEE_RATE) - base_size = (effective_fiat_amount / limit_price).quantize(base_increment, rounding=ROUND_HALF_UP) - - order_func = self.limit_order_gtc_buy if side == Side.BUY else self.limit_order_gtc_sell - order = order_func( - self._generate_client_order_id(), - product_id, - str(base_size), - str(limit_price) - ) - - self._log_order_result(order, product_id, base_size, limit_price, side) - - return order - - def _log_order_result(self, order: Dict[str, Any], product_id: str, amount: Any, price: Any = None, side: Side = None) -> None: - """ - Log the result of an order. - Args: - order (Dict[str, Any]): The order response from Coinbase. - product_id (str): The ID of the product. - amount (Any): The amount of the order. - price (Any, optional): The price of the order (for limit orders). - side (Side, optional): The side of the order (buy or sell). - """ - base_currency, quote_currency = product_id.split('-') - order_type = "limit" if price else "market" - side_str = side.name.lower() if side else "unknown" - - if order['success']: - if price: - total_amount = Decimal(amount) * Decimal(price) - logger.info(f"Successfully placed a {order_type} {side_str} order for {amount} {base_currency} " - f"(${total_amount:.2f}) at a price of {price} {quote_currency}.") - else: - logger.info(f"Successfully placed a {order_type} {side_str} order for {amount} {quote_currency} of {base_currency}.") - else: - failure_reason = order.get('failure_reason', 'Unknown') - preview_failure_reason = order.get('error_response', {}).get('preview_failure_reason', 'Unknown') - logger.error(f"Failed to place a {order_type} {side_str} order. Reason: {failure_reason}. Preview failure reason: {preview_failure_reason}") - logger.debug(f"Coinbase response: {order}") - - def get_fear_and_greed_index(self): - """ - Fetches the latest Fear and Greed Index (FGI) values from the API. - - Returns: - tuple: A tuple containing the FGI value and its classification. - """ - response = requests.get('https://api.alternative.me/fng/?limit=1') - data = response.json()['data'][0] - return int(data['value']), data['value_classification'] - - def trade_based_on_fgi_simple(self, product_id, fiat_amount, schedule=SIMPLE_SCHEDULE): - """ - Executes a trade based on the Fear and Greed Index (FGI) using a simple strategy. - - Args: - product_id (str): The ID of the product to trade. - fiat_amount (float): The amount of fiat currency to trade. - schedule (list, optional): The trading schedule. Defaults to SIMPLE_SCHEDULE. - - Returns: - dict: The response from the trade execution. - """ - - fgi, classification = self.get_fear_and_greed_index() - - # Use the provided schedule or the default one - schedule = schedule or SIMPLE_SCHEDULE - - # Sort the schedule by threshold in ascending order - schedule.sort(key=lambda x: x['threshold']) - - # Get the lower and higher threshold values - lower_threshold = schedule[0]['threshold'] - higher_threshold = schedule[-1]['threshold'] - - for condition in schedule: - if fgi <= condition['threshold']: - fiat_amount = float(fiat_amount) * condition['factor'] - if condition['action'] == 'buy': - return self.fiat_limit_buy(product_id, str(fiat_amount)) - elif lower_threshold < fgi < higher_threshold: - response = self.fiat_limit_buy(product_id, str(fiat_amount)) - else: - response = self.fiat_limit_sell(product_id, str(fiat_amount)) - return {**response, 'Fear and Greed Index': fgi, 'classification': classification} - - def trade_based_on_fgi_pro(self, product_id, fiat_amount, schedule=PRO_SCHEDULE): - """ - Executes a trade based on the Fear and Greed Index (FGI) using a professional strategy. - - Args: - product_id (str): The ID of the product to trade. - fiat_amount (float): The amount of fiat currency to trade. - schedule (list, optional): The trading schedule. Defaults to PRO_SCHEDULE. - - Returns: - dict: The response from the trade execution. - """ - - fgi, classification = self.get_fear_and_greed_index() - - # Use the provided schedule or the default one - schedule = schedule or PRO_SCHEDULE - - for condition in schedule: - if fgi <= condition['threshold']: - fiat_amount = float(fiat_amount) * condition['factor'] - if condition['action'] == 'buy': - response = self.fiat_limit_buy(product_id, str(fiat_amount)) - else: - response = self.fiat_limit_sell(product_id, str(fiat_amount)) - return {**response, 'Fear and Greed Index': fgi, 'classification': classification} diff --git a/coinbase_advanced_trader/config.py b/coinbase_advanced_trader/config.py index 1428064..26817f0 100644 --- a/coinbase_advanced_trader/config.py +++ b/coinbase_advanced_trader/config.py @@ -2,18 +2,19 @@ BUY_PRICE_MULTIPLIER = 0.9995 SELL_PRICE_MULTIPLIER = 1.005 -# Default schedule for the trade_based_on_fgi_simple function -SIMPLE_SCHEDULE = [ - {'threshold': 20, 'factor': 1.2, 'action': 'buy'}, - {'threshold': 80, 'factor': 0.8, 'action': 'sell'} -] - -# Default schedule for the trade_based_on_fgi_pro function -PRO_SCHEDULE = [ +# Default schedule for the trade_based_on_fgi function +FGI_SCHEDULE = [ {'threshold': 10, 'factor': 1.5, 'action': 'buy'}, {'threshold': 20, 'factor': 1.3, 'action': 'buy'}, {'threshold': 30, 'factor': 1.1, 'action': 'buy'}, {'threshold': 70, 'factor': 0.9, 'action': 'sell'}, {'threshold': 80, 'factor': 0.7, 'action': 'sell'}, {'threshold': 90, 'factor': 0.5, 'action': 'sell'} -] \ No newline at end of file +] + +# API endpoints +FEAR_AND_GREED_API_URL = 'https://api.alternative.me/fng/?limit=1' + +# Logging configuration +LOG_FILE_PATH = 'coinbase_advanced_trader.log' +LOG_LEVEL = 'DEBUG' \ No newline at end of file diff --git a/coinbase_advanced_trader/enhanced_rest_client.py b/coinbase_advanced_trader/enhanced_rest_client.py new file mode 100644 index 0000000..16f682c --- /dev/null +++ b/coinbase_advanced_trader/enhanced_rest_client.py @@ -0,0 +1,117 @@ +"""Enhanced REST client for Coinbase Advanced Trading API.""" + +from typing import Optional +from coinbase.rest import RESTClient +from .services.order_service import OrderService +from .services.trading_strategy_service import TradingStrategyService +from .services.price_service import PriceService +from coinbase_advanced_trader.config import BUY_PRICE_MULTIPLIER, SELL_PRICE_MULTIPLIER + + +class EnhancedRESTClient(RESTClient): + """Enhanced REST client with additional trading functionalities.""" + + def __init__(self, api_key: str, api_secret: str, **kwargs): + """ + Initialize the EnhancedRESTClient. + + Args: + api_key (str): The API key for authentication. + api_secret (str): The API secret for authentication. + **kwargs: Additional keyword arguments for RESTClient. + """ + super().__init__(api_key=api_key, api_secret=api_secret, **kwargs) + self._price_service = PriceService(self) + self._order_service = OrderService(self, self._price_service) + self._trading_strategy_service = TradingStrategyService( + self._order_service, self._price_service + ) + + def fiat_market_buy(self, product_id: str, fiat_amount: str): + """ + Place a market buy order with fiat currency. + + Args: + product_id (str): The product identifier. + fiat_amount (str): The amount of fiat currency to spend. + + Returns: + The result of the market buy order. + """ + return self._order_service.fiat_market_buy(product_id, fiat_amount) + + def fiat_market_sell(self, product_id: str, fiat_amount: str): + """ + Place a market sell order with fiat currency. + + Args: + product_id (str): The product identifier. + fiat_amount (str): The amount of fiat currency to receive. + + Returns: + The result of the market sell order. + """ + return self._order_service.fiat_market_sell(product_id, fiat_amount) + + def fiat_limit_buy( + self, + product_id: str, + fiat_amount: str, + price_multiplier: float = BUY_PRICE_MULTIPLIER + ): + """ + Place a limit buy order with fiat currency. + + Args: + product_id (str): The product identifier. + fiat_amount (str): The amount of fiat currency to spend. + price_multiplier (float): The price multiplier for the limit order. + + Returns: + The result of the limit buy order. + """ + return self._order_service.fiat_limit_buy( + product_id, fiat_amount, price_multiplier + ) + + def fiat_limit_sell( + self, + product_id: str, + fiat_amount: str, + price_multiplier: float = SELL_PRICE_MULTIPLIER + ): + """ + Place a limit sell order with fiat currency. + + Args: + product_id (str): The product identifier. + fiat_amount (str): The amount of fiat currency to receive. + price_multiplier (float): The price multiplier for the limit order. + + Returns: + The result of the limit sell order. + """ + return self._order_service.fiat_limit_sell( + product_id, fiat_amount, price_multiplier + ) + + def trade_based_on_fgi( + self, + product_id: str, + fiat_amount: str, + schedule: Optional[str] = None + ): + """ + Execute a complex trade based on the Fear and Greed Index. + + Args: + product_id (str): The product identifier. + fiat_amount (float): The amount of fiat currency to trade. + schedule (Optional[str]): The trading schedule, if any. + + Returns: + The result of the trade execution. + """ + return self._trading_strategy_service.trade_based_on_fgi( + product_id, fiat_amount, schedule + ) diff --git a/coinbase_advanced_trader/logger.py b/coinbase_advanced_trader/logger.py index 00222bb..df7b464 100644 --- a/coinbase_advanced_trader/logger.py +++ b/coinbase_advanced_trader/logger.py @@ -1,9 +1,10 @@ import logging import sys +from coinbase_advanced_trader.config import LOG_FILE_PATH, LOG_LEVEL def setup_logger(): logger = logging.getLogger('coinbase_advanced_trader') - logger.setLevel(logging.DEBUG) + logger.setLevel(getattr(logging, LOG_LEVEL)) # Console handler console_handler = logging.StreamHandler(sys.stdout) @@ -12,7 +13,7 @@ def setup_logger(): console_handler.setFormatter(console_formatter) # File handler - file_handler = logging.FileHandler('coinbase_advanced_trader.log') + file_handler = logging.FileHandler(LOG_FILE_PATH) file_handler.setLevel(logging.DEBUG) file_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s') file_handler.setFormatter(file_formatter) diff --git a/coinbase_advanced_trader/models/__init__.py b/coinbase_advanced_trader/models/__init__.py new file mode 100644 index 0000000..be9ea31 --- /dev/null +++ b/coinbase_advanced_trader/models/__init__.py @@ -0,0 +1,4 @@ +from .order import Order, OrderSide, OrderType +from .product import Product + +__all__ = ['Order', 'OrderSide', 'OrderType', 'Product'] \ No newline at end of file diff --git a/coinbase_advanced_trader/models/order.py b/coinbase_advanced_trader/models/order.py new file mode 100644 index 0000000..a294174 --- /dev/null +++ b/coinbase_advanced_trader/models/order.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass +from decimal import Decimal +from enum import Enum +from typing import Optional + +class OrderSide(Enum): + BUY = "buy" + SELL = "sell" + +class OrderType(Enum): + MARKET = "market" + LIMIT = "limit" + +@dataclass +class Order: + id: str + product_id: str + side: OrderSide + type: OrderType + size: Decimal + price: Optional[Decimal] = None + client_order_id: Optional[str] = None + status: str = "pending" + + def __post_init__(self): + if self.type == OrderType.LIMIT and self.price is None: + raise ValueError("Limit orders must have a price") + + @property + def is_buy(self) -> bool: + return self.side == OrderSide.BUY + + @property + def is_sell(self) -> bool: + return self.side == OrderSide.SELL + + @property + def is_market(self) -> bool: + return self.type == OrderType.MARKET + + @property + def is_limit(self) -> bool: + return self.type == OrderType.LIMIT \ No newline at end of file diff --git a/coinbase_advanced_trader/models/product.py b/coinbase_advanced_trader/models/product.py new file mode 100644 index 0000000..c93efa5 --- /dev/null +++ b/coinbase_advanced_trader/models/product.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from decimal import Decimal + +@dataclass +class Product: + id: str + base_currency: str + quote_currency: str + base_increment: Decimal + quote_increment: Decimal + min_market_funds: Decimal + max_market_funds: Decimal + status: str + trading_disabled: bool + + @property + def name(self) -> str: + return f"{self.base_currency}-{self.quote_currency}" + + def __str__(self) -> str: + return f"Product({self.name})" \ No newline at end of file diff --git a/coinbase_advanced_trader/services/__init__.py b/coinbase_advanced_trader/services/__init__.py new file mode 100644 index 0000000..10a4073 --- /dev/null +++ b/coinbase_advanced_trader/services/__init__.py @@ -0,0 +1,5 @@ +from .order_service import OrderService +from .price_service import PriceService +from .trading_strategy_service import TradingStrategyService + +__all__ = ['OrderService', 'PriceService', 'TradingStrategyService'] \ No newline at end of file diff --git a/coinbase_advanced_trader/services/order_service.py b/coinbase_advanced_trader/services/order_service.py new file mode 100644 index 0000000..37bae23 --- /dev/null +++ b/coinbase_advanced_trader/services/order_service.py @@ -0,0 +1,142 @@ +from typing import Dict, Any +from decimal import Decimal +import uuid +from coinbase.rest import RESTClient +from coinbase_advanced_trader.models import Order, OrderSide, OrderType +from coinbase_advanced_trader.config import BUY_PRICE_MULTIPLIER, SELL_PRICE_MULTIPLIER +from coinbase_advanced_trader.logger import logger +from coinbase_advanced_trader.utils import calculate_base_size +from .price_service import PriceService + +class OrderService: + def __init__(self, rest_client: RESTClient, price_service: PriceService): + self.rest_client = rest_client + self.MAKER_FEE_RATE = Decimal('0.006') + self.price_service = price_service + + def _generate_client_order_id(self) -> str: + return str(uuid.uuid4()) + + def fiat_market_buy(self, product_id: str, fiat_amount: str) -> Order: + order_response = self.rest_client.market_order_buy(self._generate_client_order_id(), product_id, fiat_amount) + order = Order( + id=order_response['order_id'], + product_id=product_id, + side=OrderSide.BUY, + type=OrderType.MARKET, + size=Decimal(fiat_amount) + ) + # Pass the required arguments to _log_order_result + self._log_order_result(order_response, product_id, fiat_amount) + return order + + def fiat_market_sell(self, product_id: str, fiat_amount: str) -> Order: + """ + Place a market sell order for a specified fiat amount. + + Args: + product_id (str): The ID of the product to sell (e.g., "BTC-USDC"). + fiat_amount (str): The amount of fiat currency to receive. + + Returns: + Order: The order object containing details about the executed order. + """ + spot_price = self.price_service.get_spot_price(product_id) + product_details = self.price_service.get_product_details(product_id) + base_increment = Decimal(product_details['base_increment']) + base_size = calculate_base_size(Decimal(fiat_amount), spot_price, base_increment) + + order_response = self.rest_client.market_order_sell(self._generate_client_order_id(), product_id, str(base_size)) + order = Order( + id=order_response['order_id'], + product_id=product_id, + side=OrderSide.SELL, + type=OrderType.MARKET, + size=base_size + ) + self._log_order_result(order_response, product_id, str(base_size), spot_price, OrderSide.SELL) + return order + + def fiat_limit_buy(self, product_id: str, fiat_amount: str, price_multiplier: float = BUY_PRICE_MULTIPLIER) -> Order: + return self._place_limit_order(product_id, fiat_amount, price_multiplier, OrderSide.BUY) + + def fiat_limit_sell(self, product_id: str, fiat_amount: str, price_multiplier: float = SELL_PRICE_MULTIPLIER) -> Order: + return self._place_limit_order(product_id, fiat_amount, price_multiplier, OrderSide.SELL) + + def _place_limit_order(self, product_id: str, fiat_amount: str, price_multiplier: float, side: OrderSide) -> Order: + current_price = self.price_service.get_spot_price(product_id) + adjusted_price = current_price * Decimal(price_multiplier) + product_details = self.price_service.get_product_details(product_id) + base_increment = Decimal(product_details['base_increment']) + base_size = calculate_base_size(Decimal(fiat_amount), adjusted_price, base_increment) + + order_func = self.rest_client.limit_order_buy if side == OrderSide.BUY else self.rest_client.limit_order_sell + order_response = order_func(self._generate_client_order_id(), product_id, str(base_size), str(adjusted_price)) + + order = Order( + id=order_response['order_id'], + product_id=product_id, + side=side, + type=OrderType.LIMIT, + size=base_size, + price=adjusted_price + ) + self._log_order_result(order) + return order + + def _place_limit_order(self, product_id: str, fiat_amount: str, price_multiplier: float, side: OrderSide) -> Order: + current_price = self.price_service.get_spot_price(product_id) + adjusted_price = current_price * Decimal(price_multiplier) + product_details = self.price_service.get_product_details(product_id) + base_increment = Decimal(product_details['base_increment']) + quote_increment = Decimal(product_details['quote_increment']) + + # Quantize the adjusted price and base size according to the product details + adjusted_price = adjusted_price.quantize(quote_increment) + base_size = calculate_base_size(Decimal(fiat_amount), adjusted_price, base_increment) + base_size = base_size.quantize(base_increment) + + order_func = self.rest_client.limit_order_gtc_buy if side == OrderSide.BUY else self.rest_client.limit_order_gtc_sell + order_response = order_func(self._generate_client_order_id(), product_id, str(base_size), str(adjusted_price)) + + order = Order( + id=order_response['order_id'], + product_id=product_id, + side=side, + type=OrderType.LIMIT, + size=base_size, + price=adjusted_price + ) + self._log_order_result(order_response, product_id, base_size, adjusted_price, side) + return order + + def _log_order_result(self, order: Dict[str, Any], product_id: str, amount: Any, price: Any = None, side: OrderSide = None) -> None: + """ + Log the result of an order. + + Args: + order (Dict[str, Any]): The order response from Coinbase. + product_id (str): The ID of the product. + amount (Any): The actual amount of the order. + price (Any, optional): The price of the order (for limit orders). + side (OrderSide, optional): The side of the order (buy or sell). + """ + base_currency, quote_currency = product_id.split('-') + order_type = "limit" if price else "market" + side_str = side.name.lower() if side else "unknown" + + if order['success']: + if price: + total_amount = Decimal(amount) * Decimal(price) + log_message = (f"Successfully placed a {order_type} {side_str} order for {amount} {base_currency} " + f"(${total_amount:.2f}) at a price of {price} {quote_currency}.") + else: + log_message = f"Successfully placed a {order_type} {side_str} order for {amount} {quote_currency} of {base_currency}." + + logger.info(log_message) + else: + failure_reason = order.get('failure_reason', 'Unknown') + preview_failure_reason = order.get('error_response', {}).get('preview_failure_reason', 'Unknown') + logger.error(f"Failed to place a {order_type} {side_str} order. Reason: {failure_reason}. Preview failure reason: {preview_failure_reason}") + + logger.debug(f"Coinbase response: {order}") \ No newline at end of file diff --git a/coinbase_advanced_trader/services/price_service.py b/coinbase_advanced_trader/services/price_service.py new file mode 100644 index 0000000..6a410a6 --- /dev/null +++ b/coinbase_advanced_trader/services/price_service.py @@ -0,0 +1,31 @@ +from decimal import Decimal +from typing import Dict, Any +from coinbase.rest import RESTClient +from coinbase_advanced_trader.logger import logger + +class PriceService: + def __init__(self, rest_client: RESTClient): + self.rest_client = rest_client + + def get_spot_price(self, product_id: str) -> Decimal: + try: + response = self.rest_client.get_product(product_id) + quote_increment = Decimal(response['quote_increment']) + + if 'price' in response: + price = Decimal(response['price']) + return price.quantize(quote_increment) + else: + logger.error(f"'price' field missing in response for {product_id}") + return None + + except Exception as e: + logger.error(f"Error fetching spot price for {product_id}: {e}") + return None + + def get_product_details(self, product_id: str) -> Dict[str, Any]: + response = self.rest_client.get_product(product_id) + return { + 'base_increment': Decimal(response['base_increment']), + 'quote_increment': Decimal(response['quote_increment']) + } diff --git a/coinbase_advanced_trader/services/trading_strategy_service.py b/coinbase_advanced_trader/services/trading_strategy_service.py new file mode 100644 index 0000000..dbeac0f --- /dev/null +++ b/coinbase_advanced_trader/services/trading_strategy_service.py @@ -0,0 +1,46 @@ +import requests +from typing import List, Dict, Any +from decimal import Decimal +from coinbase_advanced_trader.models import Order +from coinbase_advanced_trader.config import FGI_SCHEDULE, FEAR_AND_GREED_API_URL +from coinbase_advanced_trader.logger import logger +from .order_service import OrderService +from .price_service import PriceService + +class TradingStrategyService: + def __init__(self, order_service: OrderService, price_service: PriceService): + self.order_service = order_service + self.price_service = price_service + + def get_fear_and_greed_index(self) -> tuple: + response = requests.get(FEAR_AND_GREED_API_URL) + data = response.json()['data'][0] + return int(data['value']), data['value_classification'] + + def _execute_trade(self, product_id: str, fiat_amount: str, action: str) -> Order: + if action == 'buy': + return self.order_service.fiat_limit_buy(product_id, fiat_amount) + elif action == 'sell': + return self.order_service.fiat_limit_sell(product_id, fiat_amount) + else: + logger.error(f"Invalid action: {action}") + return None + + def trade_based_on_fgi(self, product_id: str, fiat_amount: str, schedule: List[Dict[str, Any]] = None) -> Order: + if schedule is None: + schedule = FGI_SCHEDULE # Ensure a default schedule is used if none is provided + + fgi, fgi_classification = self.get_fear_and_greed_index() + logger.info(f"FGI retrieved: {fgi} ({fgi_classification}) for trading {product_id}") + + fiat_amount = Decimal(fiat_amount) # Convert fiat_amount to Decimal before multiplication + + for condition in schedule: + if ((condition['action'] == 'buy' and fgi <= condition['threshold']) or + (condition['action'] == 'sell' and fgi >= condition['threshold'])): + adjusted_amount = fiat_amount * Decimal(condition['factor']) + logger.info(f"FGI condition met: FGI {fgi} {condition['action']} condition. Executing {condition['action']} with adjusted amount {adjusted_amount:.2f}") + return self._execute_trade(product_id, str(adjusted_amount), condition['action']) + + logger.warning(f"No trading condition met for FGI: {fgi}") + return None \ No newline at end of file diff --git a/coinbase_advanced_trader/tests/__init__.py b/coinbase_advanced_trader/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/coinbase_advanced_trader/tests/test_client.py b/coinbase_advanced_trader/tests/test_client.py new file mode 100644 index 0000000..e69de29 diff --git a/coinbase_advanced_trader/tests/test_order_service.py b/coinbase_advanced_trader/tests/test_order_service.py new file mode 100644 index 0000000..e69de29 diff --git a/coinbase_advanced_trader/tests/test_price_service.py b/coinbase_advanced_trader/tests/test_price_service.py new file mode 100644 index 0000000..e69de29 diff --git a/coinbase_advanced_trader/utils/__init__.py b/coinbase_advanced_trader/utils/__init__.py new file mode 100644 index 0000000..b778d97 --- /dev/null +++ b/coinbase_advanced_trader/utils/__init__.py @@ -0,0 +1,3 @@ +from .helpers import calculate_base_size, generate_client_order_id + +__all__ = ['calculate_base_size', 'generate_client_order_id'] \ No newline at end of file diff --git a/coinbase_advanced_trader/utils/helpers.py b/coinbase_advanced_trader/utils/helpers.py new file mode 100644 index 0000000..c2ed5bd --- /dev/null +++ b/coinbase_advanced_trader/utils/helpers.py @@ -0,0 +1,26 @@ +import uuid +from decimal import Decimal, ROUND_HALF_UP + + +def calculate_base_size(fiat_amount: Decimal, spot_price: Decimal, base_increment: Decimal) -> Decimal: + """ + Calculate the base size for an order. + + Args: + fiat_amount (Decimal): The amount in fiat currency. + spot_price (Decimal): The current spot price. + base_increment (Decimal): The base increment for the product. + + Returns: + Decimal: The calculated base size. + """ + return (fiat_amount / spot_price).quantize(base_increment, rounding=ROUND_HALF_UP) + +def generate_client_order_id() -> str: + """ + Generate a unique client order ID. + + Returns: + str: A unique UUID string. + """ + return str(uuid.uuid4()) \ No newline at end of file From 7454d42df8d155046b8286165b7f283f1c7cbe02 Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Sun, 30 Jun 2024 12:07:09 -0500 Subject: [PATCH 12/23] Update trading_strategy_service.py --- coinbase_advanced_trader/services/trading_strategy_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coinbase_advanced_trader/services/trading_strategy_service.py b/coinbase_advanced_trader/services/trading_strategy_service.py index dbeac0f..d1984de 100644 --- a/coinbase_advanced_trader/services/trading_strategy_service.py +++ b/coinbase_advanced_trader/services/trading_strategy_service.py @@ -43,4 +43,4 @@ def trade_based_on_fgi(self, product_id: str, fiat_amount: str, schedule: List[D return self._execute_trade(product_id, str(adjusted_amount), condition['action']) logger.warning(f"No trading condition met for FGI: {fgi}") - return None \ No newline at end of file + return None From 432dc645e86a4f45657b11d418f6daa55757a233 Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Fri, 5 Jul 2024 10:46:56 -0500 Subject: [PATCH 13/23] final stable verison --- coinbase_advanced_trader/config.py | 43 +++--- .../enhanced_rest_client.py | 123 ++++++++++++++---- coinbase_advanced_trader/logger.py | 5 +- coinbase_advanced_trader/services/__init__.py | 4 +- .../services/fear_and_greed_strategy.py | 72 ++++++++++ .../services/order_service.py | 2 +- .../services/trading_strategy_service.py | 43 +----- coinbase_advanced_trader/tests/__init__.py | 0 coinbase_advanced_trader/tests/test_client.py | 0 9 files changed, 205 insertions(+), 87 deletions(-) create mode 100644 coinbase_advanced_trader/services/fear_and_greed_strategy.py delete mode 100644 coinbase_advanced_trader/tests/__init__.py delete mode 100644 coinbase_advanced_trader/tests/test_client.py diff --git a/coinbase_advanced_trader/config.py b/coinbase_advanced_trader/config.py index 26817f0..e2d14ac 100644 --- a/coinbase_advanced_trader/config.py +++ b/coinbase_advanced_trader/config.py @@ -1,20 +1,29 @@ -# Default price multipliers for limit orders -BUY_PRICE_MULTIPLIER = 0.9995 -SELL_PRICE_MULTIPLIER = 1.005 +import yaml +from pathlib import Path -# Default schedule for the trade_based_on_fgi function -FGI_SCHEDULE = [ - {'threshold': 10, 'factor': 1.5, 'action': 'buy'}, - {'threshold': 20, 'factor': 1.3, 'action': 'buy'}, - {'threshold': 30, 'factor': 1.1, 'action': 'buy'}, - {'threshold': 70, 'factor': 0.9, 'action': 'sell'}, - {'threshold': 80, 'factor': 0.7, 'action': 'sell'}, - {'threshold': 90, 'factor': 0.5, 'action': 'sell'} -] +class ConfigManager: + def __init__(self): + self.config_path = Path('config.yaml') + self.config = self._load_config() -# API endpoints -FEAR_AND_GREED_API_URL = 'https://api.alternative.me/fng/?limit=1' + def _load_config(self): + default_config = { + 'BUY_PRICE_MULTIPLIER': 0.9995, + 'SELL_PRICE_MULTIPLIER': 1.005, + 'FEAR_AND_GREED_API_URL': 'https://api.alternative.me/fng/?limit=1', + 'LOG_FILE_PATH': 'coinbase_advanced_trader.log', + 'LOG_LEVEL': 'DEBUG', + 'FGI_CACHE_DURATION': 3600 + } -# Logging configuration -LOG_FILE_PATH = 'coinbase_advanced_trader.log' -LOG_LEVEL = 'DEBUG' \ No newline at end of file + if self.config_path.exists(): + with open(self.config_path, 'r') as f: + user_config = yaml.safe_load(f) + default_config.update(user_config) + + return default_config + + def get(self, key, default=None): + return self.config.get(key, default) + +config_manager = ConfigManager() \ No newline at end of file diff --git a/coinbase_advanced_trader/enhanced_rest_client.py b/coinbase_advanced_trader/enhanced_rest_client.py index 16f682c..a9b1b1c 100644 --- a/coinbase_advanced_trader/enhanced_rest_client.py +++ b/coinbase_advanced_trader/enhanced_rest_client.py @@ -1,52 +1,119 @@ """Enhanced REST client for Coinbase Advanced Trading API.""" -from typing import Optional +from typing import Optional, List, Dict, Any from coinbase.rest import RESTClient from .services.order_service import OrderService -from .services.trading_strategy_service import TradingStrategyService +from .services.fear_and_greed_strategy import FearAndGreedStrategy from .services.price_service import PriceService -from coinbase_advanced_trader.config import BUY_PRICE_MULTIPLIER, SELL_PRICE_MULTIPLIER +from coinbase_advanced_trader.trading_config import BUY_PRICE_MULTIPLIER, SELL_PRICE_MULTIPLIER +from .trading_config import TradingConfig +from coinbase_advanced_trader.logger import logger class EnhancedRESTClient(RESTClient): """Enhanced REST client with additional trading functionalities.""" - def __init__(self, api_key: str, api_secret: str, **kwargs): + def __init__(self, api_key: str, api_secret: str, **kwargs: Any) -> None: """ Initialize the EnhancedRESTClient. Args: - api_key (str): The API key for authentication. - api_secret (str): The API secret for authentication. + api_key: The API key for authentication. + api_secret: The API secret for authentication. **kwargs: Additional keyword arguments for RESTClient. """ super().__init__(api_key=api_key, api_secret=api_secret, **kwargs) self._price_service = PriceService(self) self._order_service = OrderService(self, self._price_service) - self._trading_strategy_service = TradingStrategyService( - self._order_service, self._price_service + self._config = TradingConfig() + self._fear_and_greed_strategy = FearAndGreedStrategy( + self._order_service, self._price_service, self._config ) - def fiat_market_buy(self, product_id: str, fiat_amount: str): + def update_fgi_schedule(self, new_schedule): + """ + Update the Fear and Greed Index trading schedule. + + Args: + new_schedule (List[Dict]): The new schedule to be set. + + Raises: + ValueError: If the provided schedule is invalid. + + Returns: + bool: True if the schedule was successfully updated, False otherwise. + + Example: + >>> client = EnhancedRESTClient(api_key, api_secret) + >>> new_schedule = [ + ... {'threshold': 20, 'factor': 1.2, 'action': 'buy'}, + ... {'threshold': 80, 'factor': 0.8, 'action': 'sell'} + ... ] + >>> client.update_fgi_schedule(new_schedule) + True + """ + try: + if self._config.validate_schedule(new_schedule): + self._config.update_fgi_schedule(new_schedule) + logger.info("FGI schedule successfully updated.") + return True + else: + logger.warning("Invalid FGI schedule provided. Update rejected.") + return False + except ValueError as e: + logger.error(f"Failed to update FGI schedule: {str(e)}") + raise + + def get_fgi_schedule(self) -> List[Dict[str, Any]]: + """ + Get the current Fear and Greed Index schedule. + + Returns: + The current FGI schedule. + """ + return self._config.get_fgi_schedule() + + def validate_fgi_schedule(self, schedule: List[Dict[str, Any]]) -> bool: + """ + Validate a Fear and Greed Index trading schedule without updating it. + + Args: + schedule (List[Dict[str, Any]]): The schedule to validate. + + Returns: + bool: True if the schedule is valid, False otherwise. + + Example: + >>> client = EnhancedRESTClient(api_key, api_secret) + >>> schedule = [ + ... {'threshold': 20, 'factor': 1.2, 'action': 'buy'}, + ... {'threshold': 80, 'factor': 0.8, 'action': 'sell'} + ... ] + >>> client.validate_fgi_schedule(schedule) + True + """ + return self._config.validate_schedule(schedule) + + def fiat_market_buy(self, product_id: str, fiat_amount: str) -> Dict[str, Any]: """ Place a market buy order with fiat currency. Args: - product_id (str): The product identifier. - fiat_amount (str): The amount of fiat currency to spend. + product_id: The product identifier. + fiat_amount: The amount of fiat currency to spend. Returns: The result of the market buy order. """ return self._order_service.fiat_market_buy(product_id, fiat_amount) - def fiat_market_sell(self, product_id: str, fiat_amount: str): + def fiat_market_sell(self, product_id: str, fiat_amount: str) -> Dict[str, Any]: """ Place a market sell order with fiat currency. Args: - product_id (str): The product identifier. - fiat_amount (str): The amount of fiat currency to receive. + product_id: The product identifier. + fiat_amount: The amount of fiat currency to receive. Returns: The result of the market sell order. @@ -58,14 +125,14 @@ def fiat_limit_buy( product_id: str, fiat_amount: str, price_multiplier: float = BUY_PRICE_MULTIPLIER - ): + ) -> Dict[str, Any]: """ Place a limit buy order with fiat currency. Args: - product_id (str): The product identifier. - fiat_amount (str): The amount of fiat currency to spend. - price_multiplier (float): The price multiplier for the limit order. + product_id: The product identifier. + fiat_amount: The amount of fiat currency to spend. + price_multiplier: The price multiplier for the limit order. Returns: The result of the limit buy order. @@ -79,14 +146,14 @@ def fiat_limit_sell( product_id: str, fiat_amount: str, price_multiplier: float = SELL_PRICE_MULTIPLIER - ): + ) -> Dict[str, Any]: """ Place a limit sell order with fiat currency. Args: - product_id (str): The product identifier. - fiat_amount (str): The amount of fiat currency to receive. - price_multiplier (float): The price multiplier for the limit order. + product_id: The product identifier. + fiat_amount: The amount of fiat currency to receive. + price_multiplier: The price multiplier for the limit order. Returns: The result of the limit sell order. @@ -100,18 +167,18 @@ def trade_based_on_fgi( product_id: str, fiat_amount: str, schedule: Optional[str] = None - ): + ) -> Dict[str, Any]: """ Execute a complex trade based on the Fear and Greed Index. Args: - product_id (str): The product identifier. - fiat_amount (float): The amount of fiat currency to trade. - schedule (Optional[str]): The trading schedule, if any. + product_id: The product identifier. + fiat_amount: The amount of fiat currency to trade. + schedule: The trading schedule, if any. Returns: The result of the trade execution. """ - return self._trading_strategy_service.trade_based_on_fgi( - product_id, fiat_amount, schedule + return self._fear_and_greed_strategy.execute_trade( + product_id, fiat_amount ) diff --git a/coinbase_advanced_trader/logger.py b/coinbase_advanced_trader/logger.py index df7b464..dc26f6b 100644 --- a/coinbase_advanced_trader/logger.py +++ b/coinbase_advanced_trader/logger.py @@ -1,6 +1,9 @@ import logging import sys -from coinbase_advanced_trader.config import LOG_FILE_PATH, LOG_LEVEL +from coinbase_advanced_trader.config import config_manager + +LOG_FILE_PATH = config_manager.get('LOG_FILE_PATH') +LOG_LEVEL = config_manager.get('LOG_LEVEL') def setup_logger(): logger = logging.getLogger('coinbase_advanced_trader') diff --git a/coinbase_advanced_trader/services/__init__.py b/coinbase_advanced_trader/services/__init__.py index 10a4073..0487c90 100644 --- a/coinbase_advanced_trader/services/__init__.py +++ b/coinbase_advanced_trader/services/__init__.py @@ -1,5 +1,5 @@ from .order_service import OrderService from .price_service import PriceService -from .trading_strategy_service import TradingStrategyService +from .trading_strategy_service import BaseTradingStrategy -__all__ = ['OrderService', 'PriceService', 'TradingStrategyService'] \ No newline at end of file +__all__ = ['OrderService', 'PriceService', 'BaseTradingStrategy'] \ No newline at end of file diff --git a/coinbase_advanced_trader/services/fear_and_greed_strategy.py b/coinbase_advanced_trader/services/fear_and_greed_strategy.py new file mode 100644 index 0000000..b619fc7 --- /dev/null +++ b/coinbase_advanced_trader/services/fear_and_greed_strategy.py @@ -0,0 +1,72 @@ +import time +import requests +from decimal import Decimal +from typing import Optional, Tuple +from .trading_strategy_service import BaseTradingStrategy +from coinbase_advanced_trader.config import config_manager +from coinbase_advanced_trader.trading_config import FEAR_AND_GREED_API_URL +from coinbase_advanced_trader.models import Order +from coinbase_advanced_trader.logger import logger + +class FearAndGreedStrategy(BaseTradingStrategy): + def __init__(self, order_service, price_service, config): + super().__init__(order_service, price_service) + self.config = config + self._last_fgi_fetch_time: float = 0 + self._fgi_cache: Optional[Tuple[int, str]] = None + + def execute_trade(self, product_id: str, fiat_amount: str) -> Optional[Order]: + """ + Execute a trade based on the Fear and Greed Index (FGI). + + :param product_id: The product identifier for the trade. + :param fiat_amount: The amount of fiat currency to trade. + :return: An Order object if a trade is executed, None otherwise. + """ + fgi, fgi_classification = self.get_fear_and_greed_index() + logger.info(f"FGI retrieved: {fgi} ({fgi_classification}) for trading {product_id}") + + fiat_amount = Decimal(fiat_amount) + schedule = self.config.get_fgi_schedule() + + for condition in schedule: + if ((condition['action'] == 'buy' and fgi <= condition['threshold']) or + (condition['action'] == 'sell' and fgi >= condition['threshold'])): + adjusted_amount = fiat_amount * Decimal(condition['factor']) + logger.info(f"FGI condition met: FGI {fgi} {condition['action']} condition. " + f"Executing {condition['action']} with adjusted amount {adjusted_amount:.2f}") + return self._execute_trade(product_id, str(adjusted_amount), condition['action']) + + logger.warning(f"No trading condition met for FGI: {fgi}") + return None + + def get_fear_and_greed_index(self) -> Tuple[int, str]: + """ + Retrieve the Fear and Greed Index (FGI) from the API or cache. + + :return: A tuple containing the FGI value and classification. + """ + current_time = time.time() + if not self._fgi_cache or (current_time - self._last_fgi_fetch_time > config_manager.get('FGI_CACHE_DURATION')): + response = requests.get(FEAR_AND_GREED_API_URL) + data = response.json()['data'][0] + self._fgi_cache = (int(data['value']), data['value_classification']) + self._last_fgi_fetch_time = current_time + return self._fgi_cache + + def _execute_trade(self, product_id: str, fiat_amount: str, action: str) -> Optional[Order]: + """ + Execute a buy or sell trade based on the given action. + + :param product_id: The product identifier for the trade. + :param fiat_amount: The amount of fiat currency to trade. + :param action: The trade action ('buy' or 'sell'). + :return: An Order object if the trade is executed successfully, None otherwise. + """ + if action == 'buy': + return self.order_service.fiat_limit_buy(product_id, fiat_amount) + elif action == 'sell': + return self.order_service.fiat_limit_sell(product_id, fiat_amount) + else: + logger.error(f"Invalid action: {action}") + return None \ No newline at end of file diff --git a/coinbase_advanced_trader/services/order_service.py b/coinbase_advanced_trader/services/order_service.py index 37bae23..a953b2a 100644 --- a/coinbase_advanced_trader/services/order_service.py +++ b/coinbase_advanced_trader/services/order_service.py @@ -3,7 +3,7 @@ import uuid from coinbase.rest import RESTClient from coinbase_advanced_trader.models import Order, OrderSide, OrderType -from coinbase_advanced_trader.config import BUY_PRICE_MULTIPLIER, SELL_PRICE_MULTIPLIER +from coinbase_advanced_trader.trading_config import BUY_PRICE_MULTIPLIER, SELL_PRICE_MULTIPLIER from coinbase_advanced_trader.logger import logger from coinbase_advanced_trader.utils import calculate_base_size from .price_service import PriceService diff --git a/coinbase_advanced_trader/services/trading_strategy_service.py b/coinbase_advanced_trader/services/trading_strategy_service.py index d1984de..04d48b2 100644 --- a/coinbase_advanced_trader/services/trading_strategy_service.py +++ b/coinbase_advanced_trader/services/trading_strategy_service.py @@ -1,46 +1,13 @@ -import requests -from typing import List, Dict, Any -from decimal import Decimal +from abc import ABC, abstractmethod from coinbase_advanced_trader.models import Order -from coinbase_advanced_trader.config import FGI_SCHEDULE, FEAR_AND_GREED_API_URL -from coinbase_advanced_trader.logger import logger from .order_service import OrderService from .price_service import PriceService -class TradingStrategyService: +class BaseTradingStrategy(ABC): def __init__(self, order_service: OrderService, price_service: PriceService): self.order_service = order_service self.price_service = price_service - def get_fear_and_greed_index(self) -> tuple: - response = requests.get(FEAR_AND_GREED_API_URL) - data = response.json()['data'][0] - return int(data['value']), data['value_classification'] - - def _execute_trade(self, product_id: str, fiat_amount: str, action: str) -> Order: - if action == 'buy': - return self.order_service.fiat_limit_buy(product_id, fiat_amount) - elif action == 'sell': - return self.order_service.fiat_limit_sell(product_id, fiat_amount) - else: - logger.error(f"Invalid action: {action}") - return None - - def trade_based_on_fgi(self, product_id: str, fiat_amount: str, schedule: List[Dict[str, Any]] = None) -> Order: - if schedule is None: - schedule = FGI_SCHEDULE # Ensure a default schedule is used if none is provided - - fgi, fgi_classification = self.get_fear_and_greed_index() - logger.info(f"FGI retrieved: {fgi} ({fgi_classification}) for trading {product_id}") - - fiat_amount = Decimal(fiat_amount) # Convert fiat_amount to Decimal before multiplication - - for condition in schedule: - if ((condition['action'] == 'buy' and fgi <= condition['threshold']) or - (condition['action'] == 'sell' and fgi >= condition['threshold'])): - adjusted_amount = fiat_amount * Decimal(condition['factor']) - logger.info(f"FGI condition met: FGI {fgi} {condition['action']} condition. Executing {condition['action']} with adjusted amount {adjusted_amount:.2f}") - return self._execute_trade(product_id, str(adjusted_amount), condition['action']) - - logger.warning(f"No trading condition met for FGI: {fgi}") - return None + @abstractmethod + def execute_trade(self, product_id: str, fiat_amount: str) -> Order: + pass diff --git a/coinbase_advanced_trader/tests/__init__.py b/coinbase_advanced_trader/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/coinbase_advanced_trader/tests/test_client.py b/coinbase_advanced_trader/tests/test_client.py deleted file mode 100644 index e69de29..0000000 From a62224e9cd8078e7573ecbd28b42013a1dcc7665 Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Fri, 5 Jul 2024 15:31:31 -0500 Subject: [PATCH 14/23] updated styling and implemented test classes --- coinbase_advanced_trader/config.py | 38 +-- coinbase_advanced_trader/constants.py | 8 + coinbase_advanced_trader/logger.py | 6 +- coinbase_advanced_trader/models/__init__.py | 2 + coinbase_advanced_trader/models/order.py | 24 ++ coinbase_advanced_trader/models/product.py | 28 ++ coinbase_advanced_trader/services/__init__.py | 4 +- .../services/fear_and_greed_strategy.py | 64 +++-- .../services/order_service.py | 213 ++++++++++----- .../services/price_service.py | 41 ++- .../services/trading_strategy_service.py | 40 ++- .../tests/test_config_manager.py | 49 ++++ .../tests/test_enhanced_rest_client.py | 171 ++++++++++++ .../tests/test_error_handling.py | 104 ++++++++ .../tests/test_fear_and_greed_strategy.py | 122 +++++++++ .../tests/test_order_model.py | 73 ++++++ .../tests/test_order_service.py | 245 ++++++++++++++++++ .../tests/test_price_service.py | 84 ++++++ .../tests/test_trading_config.py | 34 +++ coinbase_advanced_trader/trading_config.py | 71 +++++ coinbase_advanced_trader/utils/__init__.py | 2 + coinbase_advanced_trader/utils/helpers.py | 11 +- 22 files changed, 1330 insertions(+), 104 deletions(-) create mode 100644 coinbase_advanced_trader/constants.py create mode 100644 coinbase_advanced_trader/tests/test_config_manager.py create mode 100644 coinbase_advanced_trader/tests/test_enhanced_rest_client.py create mode 100644 coinbase_advanced_trader/tests/test_error_handling.py create mode 100644 coinbase_advanced_trader/tests/test_fear_and_greed_strategy.py create mode 100644 coinbase_advanced_trader/tests/test_order_model.py create mode 100644 coinbase_advanced_trader/tests/test_trading_config.py create mode 100644 coinbase_advanced_trader/trading_config.py diff --git a/coinbase_advanced_trader/config.py b/coinbase_advanced_trader/config.py index e2d14ac..9640a52 100644 --- a/coinbase_advanced_trader/config.py +++ b/coinbase_advanced_trader/config.py @@ -1,29 +1,37 @@ import yaml from pathlib import Path +from coinbase_advanced_trader.constants import DEFAULT_CONFIG class ConfigManager: - def __init__(self): + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance.initialize() + return cls._instance + + def initialize(self): self.config_path = Path('config.yaml') self.config = self._load_config() def _load_config(self): - default_config = { - 'BUY_PRICE_MULTIPLIER': 0.9995, - 'SELL_PRICE_MULTIPLIER': 1.005, - 'FEAR_AND_GREED_API_URL': 'https://api.alternative.me/fng/?limit=1', - 'LOG_FILE_PATH': 'coinbase_advanced_trader.log', - 'LOG_LEVEL': 'DEBUG', - 'FGI_CACHE_DURATION': 3600 - } - + config = DEFAULT_CONFIG.copy() if self.config_path.exists(): - with open(self.config_path, 'r') as f: - user_config = yaml.safe_load(f) - default_config.update(user_config) - - return default_config + try: + with open(self.config_path, 'r') as f: + user_config = yaml.safe_load(f) + if user_config: + config.update(user_config) + except Exception as e: + print(f"Error loading user config: {e}") + return config def get(self, key, default=None): return self.config.get(key, default) + + @classmethod + def reset(cls): + cls._instance = None config_manager = ConfigManager() \ No newline at end of file diff --git a/coinbase_advanced_trader/constants.py b/coinbase_advanced_trader/constants.py new file mode 100644 index 0000000..352b0db --- /dev/null +++ b/coinbase_advanced_trader/constants.py @@ -0,0 +1,8 @@ +DEFAULT_CONFIG = { + 'BUY_PRICE_MULTIPLIER': 0.9995, + 'SELL_PRICE_MULTIPLIER': 1.005, + 'FEAR_AND_GREED_API_URL': 'https://api.alternative.me/fng/?limit=1', + 'LOG_FILE_PATH': 'coinbase_advanced_trader.log', + 'LOG_LEVEL': 'DEBUG', + 'FGI_CACHE_DURATION': 3600 +} \ No newline at end of file diff --git a/coinbase_advanced_trader/logger.py b/coinbase_advanced_trader/logger.py index dc26f6b..27f8489 100644 --- a/coinbase_advanced_trader/logger.py +++ b/coinbase_advanced_trader/logger.py @@ -2,10 +2,10 @@ import sys from coinbase_advanced_trader.config import config_manager -LOG_FILE_PATH = config_manager.get('LOG_FILE_PATH') -LOG_LEVEL = config_manager.get('LOG_LEVEL') - def setup_logger(): + LOG_FILE_PATH = config_manager.get('LOG_FILE_PATH') + LOG_LEVEL = config_manager.get('LOG_LEVEL') + logger = logging.getLogger('coinbase_advanced_trader') logger.setLevel(getattr(logging, LOG_LEVEL)) diff --git a/coinbase_advanced_trader/models/__init__.py b/coinbase_advanced_trader/models/__init__.py index be9ea31..056245a 100644 --- a/coinbase_advanced_trader/models/__init__.py +++ b/coinbase_advanced_trader/models/__init__.py @@ -1,3 +1,5 @@ +"""Models package for Coinbase Advanced Trader.""" + from .order import Order, OrderSide, OrderType from .product import Product diff --git a/coinbase_advanced_trader/models/order.py b/coinbase_advanced_trader/models/order.py index a294174..cc41e3c 100644 --- a/coinbase_advanced_trader/models/order.py +++ b/coinbase_advanced_trader/models/order.py @@ -3,16 +3,35 @@ from enum import Enum from typing import Optional + class OrderSide(Enum): + """Enum representing the side of an order (buy or sell).""" BUY = "buy" SELL = "sell" + class OrderType(Enum): + """Enum representing the type of an order (market or limit).""" MARKET = "market" LIMIT = "limit" + @dataclass class Order: + """ + Represents an order in the trading system. + + Attributes: + id (str): Unique identifier for the order. + product_id (str): Identifier for the product being traded. + side (OrderSide): Whether the order is a buy or sell. + type (OrderType): Whether the order is a market or limit order. + size (Decimal): The size of the order. + price (Optional[Decimal]): The price for limit orders (None for market orders). + client_order_id (Optional[str]): Client-specified order ID. + status (str): Current status of the order. + """ + id: str product_id: str side: OrderSide @@ -23,21 +42,26 @@ class Order: status: str = "pending" def __post_init__(self): + """Validates that limit orders have a price.""" if self.type == OrderType.LIMIT and self.price is None: raise ValueError("Limit orders must have a price") @property def is_buy(self) -> bool: + """Returns True if the order is a buy order.""" return self.side == OrderSide.BUY @property def is_sell(self) -> bool: + """Returns True if the order is a sell order.""" return self.side == OrderSide.SELL @property def is_market(self) -> bool: + """Returns True if the order is a market order.""" return self.type == OrderType.MARKET @property def is_limit(self) -> bool: + """Returns True if the order is a limit order.""" return self.type == OrderType.LIMIT \ No newline at end of file diff --git a/coinbase_advanced_trader/models/product.py b/coinbase_advanced_trader/models/product.py index c93efa5..347da3f 100644 --- a/coinbase_advanced_trader/models/product.py +++ b/coinbase_advanced_trader/models/product.py @@ -1,8 +1,24 @@ from dataclasses import dataclass from decimal import Decimal + @dataclass class Product: + """ + Represents a trading product with its associated attributes. + + Attributes: + id (str): Unique identifier for the product. + base_currency (str): The base currency of the product. + quote_currency (str): The quote currency of the product. + base_increment (Decimal): Minimum increment for the base currency. + quote_increment (Decimal): Minimum increment for the quote currency. + min_market_funds (Decimal): Minimum funds required for market orders. + max_market_funds (Decimal): Maximum funds allowed for market orders. + status (str): Current status of the product. + trading_disabled (bool): Whether trading is currently disabled. + """ + id: str base_currency: str quote_currency: str @@ -15,7 +31,19 @@ class Product: @property def name(self) -> str: + """ + Returns the product name in the format 'base_currency-quote_currency'. + + Returns: + str: The product name. + """ return f"{self.base_currency}-{self.quote_currency}" def __str__(self) -> str: + """ + Returns a string representation of the Product. + + Returns: + str: A string representation of the Product. + """ return f"Product({self.name})" \ No newline at end of file diff --git a/coinbase_advanced_trader/services/__init__.py b/coinbase_advanced_trader/services/__init__.py index 0487c90..63daef7 100644 --- a/coinbase_advanced_trader/services/__init__.py +++ b/coinbase_advanced_trader/services/__init__.py @@ -1,5 +1,7 @@ +"""Services package for Coinbase Advanced Trader.""" + from .order_service import OrderService from .price_service import PriceService -from .trading_strategy_service import BaseTradingStrategy +from .trading_strategy_service import BaseTradingStrategy __all__ = ['OrderService', 'PriceService', 'BaseTradingStrategy'] \ No newline at end of file diff --git a/coinbase_advanced_trader/services/fear_and_greed_strategy.py b/coinbase_advanced_trader/services/fear_and_greed_strategy.py index b619fc7..eb839c2 100644 --- a/coinbase_advanced_trader/services/fear_and_greed_strategy.py +++ b/coinbase_advanced_trader/services/fear_and_greed_strategy.py @@ -1,15 +1,29 @@ import time -import requests from decimal import Decimal from typing import Optional, Tuple -from .trading_strategy_service import BaseTradingStrategy + +import requests + from coinbase_advanced_trader.config import config_manager -from coinbase_advanced_trader.trading_config import FEAR_AND_GREED_API_URL -from coinbase_advanced_trader.models import Order from coinbase_advanced_trader.logger import logger +from coinbase_advanced_trader.models import Order +from coinbase_advanced_trader.trading_config import FEAR_AND_GREED_API_URL +from .trading_strategy_service import BaseTradingStrategy + class FearAndGreedStrategy(BaseTradingStrategy): + """ + A trading strategy based on the Fear and Greed Index (FGI). + """ + def __init__(self, order_service, price_service, config): + """ + Initialize the FearAndGreedStrategy. + + :param order_service: Service for handling orders. + :param price_service: Service for handling prices. + :param config: Configuration object. + """ super().__init__(order_service, price_service) self.config = config self._last_fgi_fetch_time: float = 0 @@ -24,18 +38,21 @@ def execute_trade(self, product_id: str, fiat_amount: str) -> Optional[Order]: :return: An Order object if a trade is executed, None otherwise. """ fgi, fgi_classification = self.get_fear_and_greed_index() - logger.info(f"FGI retrieved: {fgi} ({fgi_classification}) for trading {product_id}") - + logger.info(f"FGI retrieved: {fgi} ({fgi_classification}) " + f"for trading {product_id}") + fiat_amount = Decimal(fiat_amount) schedule = self.config.get_fgi_schedule() for condition in schedule: - if ((condition['action'] == 'buy' and fgi <= condition['threshold']) or - (condition['action'] == 'sell' and fgi >= condition['threshold'])): + if self._should_execute_trade(condition, fgi): adjusted_amount = fiat_amount * Decimal(condition['factor']) - logger.info(f"FGI condition met: FGI {fgi} {condition['action']} condition. " - f"Executing {condition['action']} with adjusted amount {adjusted_amount:.2f}") - return self._execute_trade(product_id, str(adjusted_amount), condition['action']) + logger.info(f"FGI condition met: FGI {fgi} " + f"{condition['action']} condition. " + f"Executing {condition['action']} with " + f"adjusted amount {adjusted_amount:.2f}") + return self._execute_trade(product_id, str(adjusted_amount), + condition['action']) logger.warning(f"No trading condition met for FGI: {fgi}") return None @@ -47,21 +64,25 @@ def get_fear_and_greed_index(self) -> Tuple[int, str]: :return: A tuple containing the FGI value and classification. """ current_time = time.time() - if not self._fgi_cache or (current_time - self._last_fgi_fetch_time > config_manager.get('FGI_CACHE_DURATION')): + cache_duration = config_manager.get('FGI_CACHE_DURATION') + if (not self._fgi_cache or + (current_time - self._last_fgi_fetch_time > cache_duration)): response = requests.get(FEAR_AND_GREED_API_URL) data = response.json()['data'][0] self._fgi_cache = (int(data['value']), data['value_classification']) self._last_fgi_fetch_time = current_time return self._fgi_cache - def _execute_trade(self, product_id: str, fiat_amount: str, action: str) -> Optional[Order]: + def _execute_trade(self, product_id: str, fiat_amount: str, + action: str) -> Optional[Order]: """ Execute a buy or sell trade based on the given action. :param product_id: The product identifier for the trade. :param fiat_amount: The amount of fiat currency to trade. :param action: The trade action ('buy' or 'sell'). - :return: An Order object if the trade is executed successfully, None otherwise. + :return: An Order object if the trade is executed successfully, + None otherwise. """ if action == 'buy': return self.order_service.fiat_limit_buy(product_id, fiat_amount) @@ -69,4 +90,17 @@ def _execute_trade(self, product_id: str, fiat_amount: str, action: str) -> Opti return self.order_service.fiat_limit_sell(product_id, fiat_amount) else: logger.error(f"Invalid action: {action}") - return None \ No newline at end of file + return None + + @staticmethod + def _should_execute_trade(condition: dict, fgi: int) -> bool: + """ + Determine if a trade should be executed based on the condition and FGI. + + :param condition: The trading condition. + :param fgi: The current Fear and Greed Index value. + :return: True if the trade should be executed, False otherwise. + """ + return ((condition['action'] == 'buy' and fgi <= condition['threshold']) + or (condition['action'] == 'sell' and + fgi >= condition['threshold'])) \ No newline at end of file diff --git a/coinbase_advanced_trader/services/order_service.py b/coinbase_advanced_trader/services/order_service.py index a953b2a..f80c810 100644 --- a/coinbase_advanced_trader/services/order_service.py +++ b/coinbase_advanced_trader/services/order_service.py @@ -1,34 +1,83 @@ -from typing import Dict, Any -from decimal import Decimal import uuid +from decimal import Decimal +from typing import Dict, Any + from coinbase.rest import RESTClient + from coinbase_advanced_trader.models import Order, OrderSide, OrderType -from coinbase_advanced_trader.trading_config import BUY_PRICE_MULTIPLIER, SELL_PRICE_MULTIPLIER +from coinbase_advanced_trader.trading_config import ( + BUY_PRICE_MULTIPLIER, + SELL_PRICE_MULTIPLIER +) from coinbase_advanced_trader.logger import logger from coinbase_advanced_trader.utils import calculate_base_size from .price_service import PriceService + class OrderService: + """Service for handling order-related operations.""" + def __init__(self, rest_client: RESTClient, price_service: PriceService): + """ + Initialize the OrderService. + + Args: + rest_client (RESTClient): The REST client for API calls. + price_service (PriceService): The service for price-related operations. + """ self.rest_client = rest_client - self.MAKER_FEE_RATE = Decimal('0.006') self.price_service = price_service + self.MAKER_FEE_RATE = Decimal('0.006') def _generate_client_order_id(self) -> str: + """Generate a unique client order ID.""" return str(uuid.uuid4()) def fiat_market_buy(self, product_id: str, fiat_amount: str) -> Order: - order_response = self.rest_client.market_order_buy(self._generate_client_order_id(), product_id, fiat_amount) - order = Order( - id=order_response['order_id'], - product_id=product_id, - side=OrderSide.BUY, - type=OrderType.MARKET, - size=Decimal(fiat_amount) - ) - # Pass the required arguments to _log_order_result - self._log_order_result(order_response, product_id, fiat_amount) - return order + """ + Place a market buy order for a specified fiat amount. + + Args: + product_id (str): The ID of the product to buy. + fiat_amount (str): The amount of fiat currency to spend. + + Returns: + Order: The order object containing details about the executed order. + + Raises: + Exception: If the order placement fails. + """ + try: + order_response = self.rest_client.market_order_buy( + self._generate_client_order_id(), product_id, fiat_amount + ) + if not order_response['success']: + error_response = order_response.get('error_response', {}) + error_message = error_response.get('message', 'Unknown error') + preview_failure_reason = error_response.get('preview_failure_reason', 'Unknown') + error_log = (f"Failed to place a market buy order. " + f"Reason: {error_message}. " + f"Preview failure reason: {preview_failure_reason}") + logger.error(error_log) + raise Exception(error_log) + + order = Order( + id=order_response['order_id'], + product_id=product_id, + side=OrderSide.BUY, + type=OrderType.MARKET, + size=Decimal(fiat_amount) + ) + self._log_order_result(order_response, product_id, fiat_amount) + return order + except Exception as e: + error_message = str(e) + if "Invalid product_id" in error_message: + error_log = (f"Failed to place a market buy order. " + f"Reason: {error_message}. " + f"Preview failure reason: Unknown") + logger.error(error_log) + raise def fiat_market_sell(self, product_id: str, fiat_amount: str) -> Order: """ @@ -40,64 +89,107 @@ def fiat_market_sell(self, product_id: str, fiat_amount: str) -> Order: Returns: Order: The order object containing details about the executed order. + + Raises: + Exception: If the order placement fails. """ spot_price = self.price_service.get_spot_price(product_id) product_details = self.price_service.get_product_details(product_id) base_increment = Decimal(product_details['base_increment']) base_size = calculate_base_size(Decimal(fiat_amount), spot_price, base_increment) - - order_response = self.rest_client.market_order_sell(self._generate_client_order_id(), product_id, str(base_size)) - order = Order( - id=order_response['order_id'], - product_id=product_id, - side=OrderSide.SELL, - type=OrderType.MARKET, - size=base_size - ) - self._log_order_result(order_response, product_id, str(base_size), spot_price, OrderSide.SELL) - return order + + try: + order_response = self.rest_client.market_order_sell( + self._generate_client_order_id(), product_id, str(base_size) + ) + if not order_response['success']: + error_response = order_response.get('error_response', {}) + error_message = error_response.get('message', 'Unknown error') + preview_failure_reason = error_response.get('preview_failure_reason', 'Unknown') + error_log = (f"Failed to place a market sell order. " + f"Reason: {error_message}. " + f"Preview failure reason: {preview_failure_reason}") + logger.error(error_log) + raise Exception(error_log) + + order = Order( + id=order_response['order_id'], + product_id=product_id, + side=OrderSide.SELL, + type=OrderType.MARKET, + size=base_size + ) + self._log_order_result(order_response, product_id, str(base_size), spot_price, OrderSide.SELL) + return order + except Exception as e: + error_message = str(e) + if "Invalid product_id" in error_message: + error_log = (f"Failed to place a market sell order. " + f"Reason: {error_message}. " + f"Preview failure reason: Unknown") + logger.error(error_log) + raise def fiat_limit_buy(self, product_id: str, fiat_amount: str, price_multiplier: float = BUY_PRICE_MULTIPLIER) -> Order: + """ + Place a limit buy order for a specified fiat amount. + + Args: + product_id (str): The ID of the product to buy. + fiat_amount (str): The amount of fiat currency to spend. + price_multiplier (float): The multiplier for the current price. + + Returns: + Order: The order object containing details about the executed order. + """ return self._place_limit_order(product_id, fiat_amount, price_multiplier, OrderSide.BUY) def fiat_limit_sell(self, product_id: str, fiat_amount: str, price_multiplier: float = SELL_PRICE_MULTIPLIER) -> Order: - return self._place_limit_order(product_id, fiat_amount, price_multiplier, OrderSide.SELL) + """ + Place a limit sell order for a specified fiat amount. - def _place_limit_order(self, product_id: str, fiat_amount: str, price_multiplier: float, side: OrderSide) -> Order: - current_price = self.price_service.get_spot_price(product_id) - adjusted_price = current_price * Decimal(price_multiplier) - product_details = self.price_service.get_product_details(product_id) - base_increment = Decimal(product_details['base_increment']) - base_size = calculate_base_size(Decimal(fiat_amount), adjusted_price, base_increment) - - order_func = self.rest_client.limit_order_buy if side == OrderSide.BUY else self.rest_client.limit_order_sell - order_response = order_func(self._generate_client_order_id(), product_id, str(base_size), str(adjusted_price)) - - order = Order( - id=order_response['order_id'], - product_id=product_id, - side=side, - type=OrderType.LIMIT, - size=base_size, - price=adjusted_price - ) - self._log_order_result(order) - return order + Args: + product_id (str): The ID of the product to sell. + fiat_amount (str): The amount of fiat currency to receive. + price_multiplier (float): The multiplier for the current price. + + Returns: + Order: The order object containing details about the executed order. + """ + return self._place_limit_order(product_id, fiat_amount, price_multiplier, OrderSide.SELL) def _place_limit_order(self, product_id: str, fiat_amount: str, price_multiplier: float, side: OrderSide) -> Order: + """ + Place a limit order. + + Args: + product_id (str): The ID of the product. + fiat_amount (str): The amount of fiat currency. + price_multiplier (float): The multiplier for the current price. + side (OrderSide): The side of the order (buy or sell). + + Returns: + Order: The order object containing details about the executed order. + """ current_price = self.price_service.get_spot_price(product_id) adjusted_price = current_price * Decimal(price_multiplier) product_details = self.price_service.get_product_details(product_id) base_increment = Decimal(product_details['base_increment']) quote_increment = Decimal(product_details['quote_increment']) - # Quantize the adjusted price and base size according to the product details adjusted_price = adjusted_price.quantize(quote_increment) base_size = calculate_base_size(Decimal(fiat_amount), adjusted_price, base_increment) base_size = base_size.quantize(base_increment) - order_func = self.rest_client.limit_order_gtc_buy if side == OrderSide.BUY else self.rest_client.limit_order_gtc_sell - order_response = order_func(self._generate_client_order_id(), product_id, str(base_size), str(adjusted_price)) + order_func = (self.rest_client.limit_order_gtc_buy + if side == OrderSide.BUY + else self.rest_client.limit_order_gtc_sell) + order_response = order_func( + self._generate_client_order_id(), + product_id, + str(base_size), + str(adjusted_price) + ) order = Order( id=order_response['order_id'], @@ -115,11 +207,11 @@ def _log_order_result(self, order: Dict[str, Any], product_id: str, amount: Any, Log the result of an order. Args: - order (Dict[str, Any]): The order response from Coinbase. - product_id (str): The ID of the product. - amount (Any): The actual amount of the order. - price (Any, optional): The price of the order (for limit orders). - side (OrderSide, optional): The side of the order (buy or sell). + order (Dict[str, Any]): The order response from Coinbase. + product_id (str): The ID of the product. + amount (Any): The actual amount of the order. + price (Any, optional): The price of the order (for limit orders). + side (OrderSide, optional): The side of the order (buy or sell). """ base_currency, quote_currency = product_id.split('-') order_type = "limit" if price else "market" @@ -128,15 +220,18 @@ def _log_order_result(self, order: Dict[str, Any], product_id: str, amount: Any, if order['success']: if price: total_amount = Decimal(amount) * Decimal(price) - log_message = (f"Successfully placed a {order_type} {side_str} order for {amount} {base_currency} " + log_message = (f"Successfully placed a {order_type} {side_str} order " + f"for {amount} {base_currency} " f"(${total_amount:.2f}) at a price of {price} {quote_currency}.") else: - log_message = f"Successfully placed a {order_type} {side_str} order for {amount} {quote_currency} of {base_currency}." - + log_message = (f"Successfully placed a {order_type} {side_str} order " + f"for {amount} {quote_currency} of {base_currency}.") logger.info(log_message) else: failure_reason = order.get('failure_reason', 'Unknown') preview_failure_reason = order.get('error_response', {}).get('preview_failure_reason', 'Unknown') - logger.error(f"Failed to place a {order_type} {side_str} order. Reason: {failure_reason}. Preview failure reason: {preview_failure_reason}") + logger.error(f"Failed to place a {order_type} {side_str} order. " + f"Reason: {failure_reason}. " + f"Preview failure reason: {preview_failure_reason}") logger.debug(f"Coinbase response: {order}") \ No newline at end of file diff --git a/coinbase_advanced_trader/services/price_service.py b/coinbase_advanced_trader/services/price_service.py index 6a410a6..52f1b59 100644 --- a/coinbase_advanced_trader/services/price_service.py +++ b/coinbase_advanced_trader/services/price_service.py @@ -1,13 +1,33 @@ from decimal import Decimal -from typing import Dict, Any +from typing import Dict, Any, Optional + from coinbase.rest import RESTClient + from coinbase_advanced_trader.logger import logger + class PriceService: + """Service for handling price-related operations.""" + def __init__(self, rest_client: RESTClient): + """ + Initialize the PriceService. + + Args: + rest_client (RESTClient): The REST client for API calls. + """ self.rest_client = rest_client - def get_spot_price(self, product_id: str) -> Decimal: + def get_spot_price(self, product_id: str) -> Optional[Decimal]: + """ + Get the spot price for a given product. + + Args: + product_id (str): The ID of the product. + + Returns: + Optional[Decimal]: The spot price, or None if an error occurs. + """ try: response = self.rest_client.get_product(product_id) quote_increment = Decimal(response['quote_increment']) @@ -15,15 +35,24 @@ def get_spot_price(self, product_id: str) -> Decimal: if 'price' in response: price = Decimal(response['price']) return price.quantize(quote_increment) - else: - logger.error(f"'price' field missing in response for {product_id}") - return None + + logger.error(f"'price' field missing in response for {product_id}") + return None except Exception as e: logger.error(f"Error fetching spot price for {product_id}: {e}") return None - def get_product_details(self, product_id: str) -> Dict[str, Any]: + def get_product_details(self, product_id: str) -> Dict[str, Decimal]: + """ + Get the details of a product. + + Args: + product_id (str): The ID of the product. + + Returns: + Dict[str, Decimal]: A dictionary containing base and quote increments. + """ response = self.rest_client.get_product(product_id) return { 'base_increment': Decimal(response['base_increment']), diff --git a/coinbase_advanced_trader/services/trading_strategy_service.py b/coinbase_advanced_trader/services/trading_strategy_service.py index 04d48b2..f909ae3 100644 --- a/coinbase_advanced_trader/services/trading_strategy_service.py +++ b/coinbase_advanced_trader/services/trading_strategy_service.py @@ -1,13 +1,47 @@ from abc import ABC, abstractmethod +from typing import Optional + from coinbase_advanced_trader.models import Order from .order_service import OrderService from .price_service import PriceService + class BaseTradingStrategy(ABC): - def __init__(self, order_service: OrderService, price_service: PriceService): + """ + Abstract base class for trading strategies. + + This class provides a common interface for all trading strategies, + including access to order and price services. + """ + + def __init__( + self, + order_service: OrderService, + price_service: PriceService + ) -> None: + """ + Initialize the trading strategy. + + Args: + order_service (OrderService): Service for handling orders. + price_service (PriceService): Service for handling price-related operations. + """ self.order_service = order_service self.price_service = price_service @abstractmethod - def execute_trade(self, product_id: str, fiat_amount: str) -> Order: - pass + def execute_trade(self, product_id: str, fiat_amount: str) -> Optional[Order]: + """ + Execute a trade based on the strategy. + + Args: + product_id (str): The ID of the product to trade. + fiat_amount (str): The amount of fiat currency to trade. + + Returns: + Optional[Order]: The executed order, or None if the trade failed. + + Raises: + NotImplementedError: If the method is not implemented by a subclass. + """ + raise NotImplementedError("Subclasses must implement execute_trade method") \ No newline at end of file diff --git a/coinbase_advanced_trader/tests/test_config_manager.py b/coinbase_advanced_trader/tests/test_config_manager.py new file mode 100644 index 0000000..6dc1ca8 --- /dev/null +++ b/coinbase_advanced_trader/tests/test_config_manager.py @@ -0,0 +1,49 @@ +import unittest +from unittest.mock import patch, mock_open +from coinbase_advanced_trader.config import ConfigManager +from coinbase_advanced_trader.constants import DEFAULT_CONFIG + + +class TestConfigManager(unittest.TestCase): + """Test cases for the ConfigManager class.""" + + def setUp(self): + """Set up the test environment before each test method.""" + self.default_config = DEFAULT_CONFIG + ConfigManager.reset() + + @patch('coinbase_advanced_trader.config.Path.exists') + @patch('builtins.open', new_callable=mock_open) + @patch('yaml.safe_load') + def test_load_config_with_existing_file(self, mock_yaml_load, mock_file, + mock_exists): + """Test loading config from an existing file.""" + mock_exists.return_value = True + mock_yaml_load.return_value = { + 'BUY_PRICE_MULTIPLIER': 0.9990, + 'SELL_PRICE_MULTIPLIER': 1.010, + 'LOG_LEVEL': 'INFO' + } + + config_manager = ConfigManager() + test_config = config_manager.config + + self.assertEqual(test_config['BUY_PRICE_MULTIPLIER'], 0.9990) + self.assertEqual(test_config['SELL_PRICE_MULTIPLIER'], 1.010) + self.assertEqual(test_config['LOG_LEVEL'], 'INFO') + self.assertEqual(test_config['FEAR_AND_GREED_API_URL'], + self.default_config['FEAR_AND_GREED_API_URL']) + + @patch('coinbase_advanced_trader.config.Path.exists') + def test_load_config_without_existing_file(self, mock_exists): + """Test loading config when the file doesn't exist.""" + mock_exists.return_value = False + + config_manager = ConfigManager() + test_config = config_manager.config + + self.assertEqual(test_config, self.default_config) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/coinbase_advanced_trader/tests/test_enhanced_rest_client.py b/coinbase_advanced_trader/tests/test_enhanced_rest_client.py new file mode 100644 index 0000000..d2fa8ae --- /dev/null +++ b/coinbase_advanced_trader/tests/test_enhanced_rest_client.py @@ -0,0 +1,171 @@ +import unittest +from unittest.mock import Mock, patch +from decimal import Decimal + +from coinbase_advanced_trader.enhanced_rest_client import EnhancedRESTClient +from coinbase_advanced_trader.models import Order, OrderSide, OrderType +from coinbase_advanced_trader.services.order_service import OrderService +from coinbase_advanced_trader.services.price_service import PriceService +from coinbase_advanced_trader.services.fear_and_greed_strategy import FearAndGreedStrategy +from coinbase_advanced_trader.trading_config import TradingConfig + + +class TestEnhancedRESTClient(unittest.TestCase): + """Test cases for the EnhancedRESTClient class.""" + + def setUp(self): + """Set up the test environment before each test method.""" + self.api_key = "test_api_key" + self.api_secret = "test_api_secret" + self.client = EnhancedRESTClient(self.api_key, self.api_secret) + self.client._order_service = Mock(spec=OrderService) + self.client._price_service = Mock(spec=PriceService) + self.client._fear_and_greed_strategy = Mock(spec=FearAndGreedStrategy) + self.client._config = Mock(spec=TradingConfig) + + def test_fiat_market_buy(self): + """Test the fiat_market_buy method.""" + product_id = "BTC-USDC" + fiat_amount = "10" + mock_order = Order( + id='007e54c1-9e53-4afc-93f1-92cd5e98bc20', + product_id=product_id, + side=OrderSide.BUY, + type=OrderType.MARKET, + size=Decimal(fiat_amount) + ) + self.client._order_service.fiat_market_buy.return_value = mock_order + + result = self.client.fiat_market_buy(product_id, fiat_amount) + + self.client._order_service.fiat_market_buy.assert_called_once_with( + product_id, fiat_amount + ) + self.assertEqual(result, mock_order) + + def test_fiat_market_sell(self): + """Test the fiat_market_sell method.""" + product_id = "BTC-USDC" + fiat_amount = "10" + mock_order = Order( + id='007e54c1-9e53-4afc-93f1-92cd5e98bc20', + product_id=product_id, + side=OrderSide.SELL, + type=OrderType.MARKET, + size=Decimal('0.0002') + ) + self.client._order_service.fiat_market_sell.return_value = mock_order + + result = self.client.fiat_market_sell(product_id, fiat_amount) + + self.client._order_service.fiat_market_sell.assert_called_once_with( + product_id, fiat_amount + ) + self.assertEqual(result, mock_order) + + def test_fiat_limit_buy(self): + """Test the fiat_limit_buy method.""" + product_id = "BTC-USDC" + fiat_amount = "10" + price_multiplier = 0.9995 + mock_order = Order( + id='fb67bb54-73ba-41ec-a038-9883664325b7', + product_id=product_id, + side=OrderSide.BUY, + type=OrderType.LIMIT, + size=Decimal('0.0002'), + price=Decimal('49975.00') + ) + self.client._order_service.fiat_limit_buy.return_value = mock_order + + result = self.client.fiat_limit_buy( + product_id, fiat_amount, price_multiplier + ) + + self.client._order_service.fiat_limit_buy.assert_called_once_with( + product_id, fiat_amount, price_multiplier + ) + self.assertEqual(result, mock_order) + + def test_fiat_limit_sell(self): + """Test the fiat_limit_sell method.""" + product_id = "BTC-USDC" + fiat_amount = "10" + price_multiplier = 1.005 + mock_order = Order( + id='fb67bb54-73ba-41ec-a038-9883664325b7', + product_id=product_id, + side=OrderSide.SELL, + type=OrderType.LIMIT, + size=Decimal('0.0002'), + price=Decimal('50250.00') + ) + self.client._order_service.fiat_limit_sell.return_value = mock_order + + result = self.client.fiat_limit_sell( + product_id, fiat_amount, price_multiplier + ) + + self.client._order_service.fiat_limit_sell.assert_called_once_with( + product_id, fiat_amount, price_multiplier + ) + self.assertEqual(result, mock_order) + + def test_trade_based_on_fgi(self): + """Test the trade_based_on_fgi method.""" + product_id = "BTC-USDC" + fiat_amount = "10" + mock_result = {"status": "success", "order_id": "123456"} + self.client._fear_and_greed_strategy.execute_trade.return_value = mock_result + + result = self.client.trade_based_on_fgi(product_id, fiat_amount) + + self.client._fear_and_greed_strategy.execute_trade.assert_called_once_with( + product_id, fiat_amount + ) + self.assertEqual(result, mock_result) + + def test_update_fgi_schedule(self): + """Test the update_fgi_schedule method.""" + new_schedule = [ + {'threshold': 20, 'factor': 1.2, 'action': 'buy'}, + {'threshold': 80, 'factor': 0.8, 'action': 'sell'} + ] + self.client._config.validate_schedule.return_value = True + self.client._config.update_fgi_schedule.return_value = None + + result = self.client.update_fgi_schedule(new_schedule) + + self.client._config.validate_schedule.assert_called_once_with(new_schedule) + self.client._config.update_fgi_schedule.assert_called_once_with(new_schedule) + self.assertTrue(result) + + def test_get_fgi_schedule(self): + """Test the get_fgi_schedule method.""" + mock_schedule = [ + {'threshold': 20, 'factor': 1.2, 'action': 'buy'}, + {'threshold': 80, 'factor': 0.8, 'action': 'sell'} + ] + self.client._config.get_fgi_schedule.return_value = mock_schedule + + result = self.client.get_fgi_schedule() + + self.client._config.get_fgi_schedule.assert_called_once() + self.assertEqual(result, mock_schedule) + + def test_validate_fgi_schedule(self): + """Test the validate_fgi_schedule method.""" + schedule = [ + {'threshold': 20, 'factor': 1.2, 'action': 'buy'}, + {'threshold': 80, 'factor': 0.8, 'action': 'sell'} + ] + self.client._config.validate_schedule.return_value = True + + result = self.client.validate_fgi_schedule(schedule) + + self.client._config.validate_schedule.assert_called_once_with(schedule) + self.assertTrue(result) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/coinbase_advanced_trader/tests/test_error_handling.py b/coinbase_advanced_trader/tests/test_error_handling.py new file mode 100644 index 0000000..2335133 --- /dev/null +++ b/coinbase_advanced_trader/tests/test_error_handling.py @@ -0,0 +1,104 @@ +import unittest +from unittest.mock import Mock, patch + +from coinbase.rest import RESTClient + +from coinbase_advanced_trader.services.order_service import OrderService +from coinbase_advanced_trader.services.price_service import PriceService + + +class TestErrorHandling(unittest.TestCase): + """Test cases for error handling in OrderService.""" + + def setUp(self): + """Set up the test environment before each test method.""" + self.rest_client_mock = Mock(spec=RESTClient) + self.price_service_mock = Mock(spec=PriceService) + self.order_service = OrderService( + self.rest_client_mock, self.price_service_mock + ) + + @patch('coinbase_advanced_trader.services.order_service.logger') + def test_invalid_product_id(self, mock_logger): + """Test handling of invalid product ID.""" + product_id = "BTC-USDDC" + fiat_amount = "100" + + self.rest_client_mock.market_order_buy.side_effect = Exception( + "Invalid product_id" + ) + + with self.assertRaises(Exception): + self.order_service.fiat_market_buy(product_id, fiat_amount) + + mock_logger.error.assert_called_with( + "Failed to place a market buy order. " + "Reason: Invalid product_id. " + "Preview failure reason: Unknown" + ) + + @patch('coinbase_advanced_trader.services.order_service.logger') + def test_insufficient_funds(self, mock_logger): + """Test handling of insufficient funds error.""" + product_id = "BTC-USDC" + fiat_amount = "100000" + + error_response = { + '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': {'market_market_ioc': {'quote_size': '100000'}} + } + self.rest_client_mock.market_order_buy.return_value = error_response + + with self.assertRaises(Exception) as context: + self.order_service.fiat_market_buy(product_id, fiat_amount) + + self.assertIn("Failed to place a market buy order", str(context.exception)) + mock_logger.error.assert_called_once_with( + "Failed to place a market buy order. " + "Reason: Insufficient balance in source account. " + "Preview failure reason: PREVIEW_INSUFFICIENT_FUND" + ) + + @patch('coinbase_advanced_trader.services.order_service.logger') + def test_quote_size_too_high(self, mock_logger): + """Test handling of quote size too high error.""" + product_id = "BTC-USDC" + fiat_amount = "100000000000000000000" + + error_response = { + 'success': False, + 'failure_reason': 'UNKNOWN_FAILURE_REASON', + 'order_id': '', + 'error_response': { + 'error': 'UNKNOWN_FAILURE_REASON', + 'message': '', + 'error_details': '', + 'preview_failure_reason': 'PREVIEW_INVALID_QUOTE_SIZE_TOO_LARGE' + }, + 'order_configuration': { + 'market_market_ioc': {'quote_size': '100000000000000000000'} + } + } + self.rest_client_mock.market_order_buy.return_value = error_response + + with self.assertRaises(Exception) as context: + self.order_service.fiat_market_buy(product_id, fiat_amount) + + self.assertIn("Failed to place a market buy order", str(context.exception)) + mock_logger.error.assert_called_once_with( + "Failed to place a market buy order. " + "Reason: . " + "Preview failure reason: PREVIEW_INVALID_QUOTE_SIZE_TOO_LARGE" + ) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/coinbase_advanced_trader/tests/test_fear_and_greed_strategy.py b/coinbase_advanced_trader/tests/test_fear_and_greed_strategy.py new file mode 100644 index 0000000..fe27d64 --- /dev/null +++ b/coinbase_advanced_trader/tests/test_fear_and_greed_strategy.py @@ -0,0 +1,122 @@ +import unittest +from unittest.mock import Mock, patch +from decimal import Decimal + +from coinbase_advanced_trader.services.fear_and_greed_strategy import FearAndGreedStrategy +from coinbase_advanced_trader.models import Order, OrderSide, OrderType +from coinbase_advanced_trader.services.order_service import OrderService +from coinbase_advanced_trader.services.price_service import PriceService +from coinbase_advanced_trader.trading_config import TradingConfig + + +class TestFearAndGreedStrategy(unittest.TestCase): + """Test cases for the FearAndGreedStrategy class.""" + + def setUp(self): + """Set up the test environment before each test method.""" + self.order_service_mock = Mock(spec=OrderService) + self.price_service_mock = Mock(spec=PriceService) + self.config_mock = Mock(spec=TradingConfig) + self.strategy = FearAndGreedStrategy( + self.order_service_mock, + self.price_service_mock, + self.config_mock + ) + + @patch('coinbase_advanced_trader.services.fear_and_greed_strategy.requests.get') + def test_execute_trade_buy(self, mock_get): + """Test execute_trade method for a buy scenario.""" + mock_get.return_value.json.return_value = { + 'data': [{'value': '25', 'value_classification': 'Extreme Fear'}] + } + + self.config_mock.get_fgi_schedule.return_value = [ + {'threshold': 30, 'factor': 1.2, 'action': 'buy'}, + {'threshold': 70, 'factor': 0.8, 'action': 'sell'} + ] + + mock_order = Order( + id='fb67bb54-73ba-41ec-a038-9883664325b7', + product_id='BTC-USDC', + side=OrderSide.BUY, + type=OrderType.LIMIT, + size=Decimal('0.0002'), + price=Decimal('50000.00') + ) + self.order_service_mock.fiat_limit_buy.return_value = mock_order + + result = self.strategy.execute_trade('BTC-USDC', '10') + + self.assertEqual(result, mock_order) + self.order_service_mock.fiat_limit_buy.assert_called_once_with( + 'BTC-USDC', '12.00' + ) + mock_get.assert_called_once() + self.config_mock.get_fgi_schedule.assert_called_once() + + @patch('coinbase_advanced_trader.services.fear_and_greed_strategy.requests.get') + def test_execute_trade_sell(self, mock_get): + """Test execute_trade method for a sell scenario.""" + mock_get.return_value.json.return_value = { + 'data': [{'value': '75', 'value_classification': 'Extreme Greed'}] + } + + self.config_mock.get_fgi_schedule.return_value = [ + {'threshold': 30, 'factor': 1.2, 'action': 'buy'}, + {'threshold': 70, 'factor': 0.8, 'action': 'sell'} + ] + + mock_order = Order( + id='fb67bb54-73ba-41ec-a038-9883664325b7', + product_id='BTC-USDC', + side=OrderSide.SELL, + type=OrderType.LIMIT, + size=Decimal('0.0002'), + price=Decimal('50000.00') + ) + self.order_service_mock.fiat_limit_sell.return_value = mock_order + + result = self.strategy.execute_trade('BTC-USDC', '10') + + self.assertEqual(result, mock_order) + self.order_service_mock.fiat_limit_sell.assert_called_once_with( + 'BTC-USDC', '8.00' + ) + mock_get.assert_called_once() + self.config_mock.get_fgi_schedule.assert_called_once() + + @patch('coinbase_advanced_trader.services.fear_and_greed_strategy.requests.get') + def test_execute_trade_no_condition_met(self, mock_get): + """Test execute_trade method when no condition is met.""" + mock_get.return_value.json.return_value = { + 'data': [{'value': '50', 'value_classification': 'Neutral'}] + } + + self.config_mock.get_fgi_schedule.return_value = [ + {'threshold': 30, 'factor': 1.2, 'action': 'buy'}, + {'threshold': 70, 'factor': 0.8, 'action': 'sell'} + ] + + result = self.strategy.execute_trade('BTC-USDC', '10') + + self.assertIsNone(result) + self.order_service_mock.fiat_limit_buy.assert_not_called() + self.order_service_mock.fiat_limit_sell.assert_not_called() + mock_get.assert_called_once() + self.config_mock.get_fgi_schedule.assert_called_once() + + @patch('coinbase_advanced_trader.services.fear_and_greed_strategy.requests.get') + def test_get_fear_and_greed_index(self, mock_get): + """Test get_fear_and_greed_index method.""" + mock_get.return_value.json.return_value = { + 'data': [{'value': '47', 'value_classification': 'Neutral'}] + } + + result = self.strategy.get_fear_and_greed_index() + + self.assertEqual(result, (47, 'Neutral')) + mock_get.assert_called_once() + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/coinbase_advanced_trader/tests/test_order_model.py b/coinbase_advanced_trader/tests/test_order_model.py new file mode 100644 index 0000000..b50ef77 --- /dev/null +++ b/coinbase_advanced_trader/tests/test_order_model.py @@ -0,0 +1,73 @@ +import unittest +from decimal import Decimal + +from coinbase_advanced_trader.models.order import Order, OrderSide, OrderType + + +class TestOrderModel(unittest.TestCase): + """Test cases for the Order model.""" + + def test_order_creation(self): + """Test the creation of an Order instance.""" + order = Order( + id='fb67bb54-73ba-41ec-a038-9883664325b7', + product_id='BTC-USDC', + side=OrderSide.BUY, + type=OrderType.LIMIT, + size=Decimal('0.01'), + price=Decimal('10000'), + client_order_id='12345678901' + ) + + self.assertEqual(order.id, 'fb67bb54-73ba-41ec-a038-9883664325b7') + self.assertEqual(order.product_id, 'BTC-USDC') + self.assertEqual(order.side, OrderSide.BUY) + self.assertEqual(order.type, OrderType.LIMIT) + self.assertEqual(order.size, Decimal('0.01')) + self.assertEqual(order.price, Decimal('10000')) + self.assertEqual(order.client_order_id, '12345678901') + self.assertEqual(order.status, 'pending') + + def test_order_properties(self): + """Test the properties of different Order types.""" + buy_market_order = Order( + id='007e54c1-9e53-4afc-93f1-92cd5e98bc20', + product_id='BTC-USDC', + side=OrderSide.BUY, + type=OrderType.MARKET, + size=Decimal('10') + ) + + self.assertTrue(buy_market_order.is_buy) + self.assertFalse(buy_market_order.is_sell) + self.assertTrue(buy_market_order.is_market) + self.assertFalse(buy_market_order.is_limit) + + sell_limit_order = Order( + id='fb67bb54-73ba-41ec-a038-9883664325b7', + product_id='BTC-USDC', + side=OrderSide.SELL, + type=OrderType.LIMIT, + size=Decimal('0.01'), + price=Decimal('10000') + ) + + self.assertFalse(sell_limit_order.is_buy) + self.assertTrue(sell_limit_order.is_sell) + self.assertFalse(sell_limit_order.is_market) + self.assertTrue(sell_limit_order.is_limit) + + def test_limit_order_without_price(self): + """Test that creating a limit order without price raises ValueError.""" + with self.assertRaises(ValueError): + Order( + id='test-id', + product_id='BTC-USDC', + side=OrderSide.BUY, + type=OrderType.LIMIT, + size=Decimal('0.01') + ) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/coinbase_advanced_trader/tests/test_order_service.py b/coinbase_advanced_trader/tests/test_order_service.py index e69de29..4ee9573 100644 --- a/coinbase_advanced_trader/tests/test_order_service.py +++ b/coinbase_advanced_trader/tests/test_order_service.py @@ -0,0 +1,245 @@ +import unittest +from unittest.mock import Mock, patch +from decimal import Decimal + +from coinbase_advanced_trader.models import Order, OrderSide, OrderType +from coinbase_advanced_trader.services.order_service import OrderService +from coinbase_advanced_trader.services.price_service import PriceService + + +class TestOrderService(unittest.TestCase): + """Test cases for the OrderService class.""" + + def setUp(self): + """Set up the test environment before each test method.""" + self.rest_client_mock = Mock() + self.price_service_mock = Mock(spec=PriceService) + self.order_service = OrderService(self.rest_client_mock, self.price_service_mock) + + def test_fiat_market_buy(self): + """Test the fiat_market_buy method.""" + product_id = "BTC-USDC" + fiat_amount = "10" + mock_response = { + 'success': True, + 'order_id': '007e54c1-9e53-4afc-93f1-92cd5e98bc20', + 'success_response': { + 'order_id': '007e54c1-9e53-4afc-93f1-92cd5e98bc20', + 'product_id': 'BTC-USDC', + 'side': 'BUY', + 'client_order_id': '1234567890' + }, + 'order_configuration': {'market_market_ioc': {'quote_size': '10'}} + } + self.rest_client_mock.market_order_buy.return_value = mock_response + + order = self.order_service.fiat_market_buy(product_id, fiat_amount) + + self.assertIsInstance(order, Order) + self.assertEqual(order.id, '007e54c1-9e53-4afc-93f1-92cd5e98bc20') + self.assertEqual(order.product_id, 'BTC-USDC') + self.assertEqual(order.side, OrderSide.BUY) + self.assertEqual(order.type, OrderType.MARKET) + self.assertEqual(order.size, Decimal('10')) + + def test_fiat_market_sell(self): + """Test the fiat_market_sell method.""" + product_id = "BTC-USDC" + fiat_amount = "10" + mock_spot_price = Decimal('50000') + mock_product_details = {'base_increment': '0.00000001'} + self.price_service_mock.get_spot_price.return_value = mock_spot_price + self.price_service_mock.get_product_details.return_value = mock_product_details + + mock_response = { + 'success': True, + 'order_id': '007e54c1-9e53-4afc-93f1-92cd5e98bc20', + 'success_response': { + 'order_id': '007e54c1-9e53-4afc-93f1-92cd5e98bc20', + 'product_id': 'BTC-USDC', + 'side': 'SELL', + 'client_order_id': '1234567890' + }, + 'order_configuration': {'market_market_ioc': {'base_size': '0.00020000'}} + } + self.rest_client_mock.market_order_sell.return_value = mock_response + + order = self.order_service.fiat_market_sell(product_id, fiat_amount) + + self.assertIsInstance(order, Order) + self.assertEqual(order.id, '007e54c1-9e53-4afc-93f1-92cd5e98bc20') + self.assertEqual(order.product_id, 'BTC-USDC') + self.assertEqual(order.side, OrderSide.SELL) + self.assertEqual(order.type, OrderType.MARKET) + self.assertEqual(order.size, Decimal('0.00020000')) + + def test_fiat_limit_buy(self): + """Test the fiat_limit_buy method.""" + product_id = "BTC-USDC" + fiat_amount = "10" + price_multiplier = Decimal('0.9995') + + mock_spot_price = Decimal('50000') + mock_product_details = { + 'base_increment': '0.00000001', + 'quote_increment': '0.01' + } + self.price_service_mock.get_spot_price.return_value = mock_spot_price + self.price_service_mock.get_product_details.return_value = mock_product_details + + mock_response = { + 'success': True, + 'order_id': 'fb67bb54-73ba-41ec-a038-9883664325b7', + 'success_response': { + 'order_id': 'fb67bb54-73ba-41ec-a038-9883664325b7', + 'product_id': 'BTC-USDC', + 'side': 'BUY', + 'client_order_id': '12345678901' + }, + 'order_configuration': { + 'limit_limit_gtc': { + 'base_size': '0.00020010', + 'limit_price': '49975.00', + 'post_only': False + } + } + } + self.rest_client_mock.limit_order_gtc_buy.return_value = mock_response + + order = self.order_service.fiat_limit_buy(product_id, fiat_amount, price_multiplier) + + self.assertIsInstance(order, Order) + self.assertEqual(order.id, 'fb67bb54-73ba-41ec-a038-9883664325b7') + self.assertEqual(order.product_id, 'BTC-USDC') + self.assertEqual(order.side, OrderSide.BUY) + self.assertEqual(order.type, OrderType.LIMIT) + self.assertEqual(order.size, Decimal('0.00020010')) + self.assertEqual(order.price, Decimal('49975.00')) + + def test_fiat_limit_sell(self): + """Test the fiat_limit_sell method.""" + product_id = "BTC-USDC" + fiat_amount = "10" + price_multiplier = Decimal('1.005') + + mock_spot_price = Decimal('50000') + mock_product_details = { + 'base_increment': '0.00000001', + 'quote_increment': '0.01' + } + self.price_service_mock.get_spot_price.return_value = mock_spot_price + self.price_service_mock.get_product_details.return_value = mock_product_details + + mock_response = { + 'success': True, + 'order_id': 'fb67bb54-73ba-41ec-a038-9883664325b7', + 'success_response': { + 'order_id': 'fb67bb54-73ba-41ec-a038-9883664325b7', + 'product_id': 'BTC-USDC', + 'side': 'SELL', + 'client_order_id': '12345678901' + }, + 'order_configuration': { + 'limit_limit_gtc': { + 'base_size': '0.00019900', + 'limit_price': '50250.00', + 'post_only': False + } + } + } + self.rest_client_mock.limit_order_gtc_sell.return_value = mock_response + + order = self.order_service.fiat_limit_sell(product_id, fiat_amount, price_multiplier) + + self.assertIsInstance(order, Order) + self.assertEqual(order.id, 'fb67bb54-73ba-41ec-a038-9883664325b7') + self.assertEqual(order.product_id, 'BTC-USDC') + self.assertEqual(order.side, OrderSide.SELL) + self.assertEqual(order.type, OrderType.LIMIT) + self.assertEqual(order.size, Decimal('0.00019900')) + self.assertEqual(order.price, Decimal('50250.00')) + + def test_place_limit_order(self): + """Test the _place_limit_order method.""" + product_id = "BTC-USDC" + fiat_amount = "10" + price_multiplier = Decimal('0.9995') + side = OrderSide.BUY + + mock_spot_price = Decimal('50000') + mock_product_details = { + 'base_increment': '0.00000001', + 'quote_increment': '0.01' + } + self.price_service_mock.get_spot_price.return_value = mock_spot_price + self.price_service_mock.get_product_details.return_value = mock_product_details + + mock_response = { + 'success': True, + 'order_id': 'fb67bb54-73ba-41ec-a038-9883664325b7', + 'success_response': { + 'order_id': 'fb67bb54-73ba-41ec-a038-9883664325b7', + 'product_id': 'BTC-USDC', + 'side': 'BUY', + 'client_order_id': '12345678901' + }, + 'order_configuration': { + 'limit_limit_gtc': { + 'base_size': '0.00020010', + 'limit_price': '49975.00', + 'post_only': False + } + } + } + self.rest_client_mock.limit_order_gtc_buy.return_value = mock_response + + order = self.order_service._place_limit_order( + product_id, fiat_amount, price_multiplier, side + ) + + self.assertIsInstance(order, Order) + self.assertEqual(order.id, 'fb67bb54-73ba-41ec-a038-9883664325b7') + self.assertEqual(order.product_id, 'BTC-USDC') + self.assertEqual(order.side, OrderSide.BUY) + self.assertEqual(order.type, OrderType.LIMIT) + self.assertEqual(order.size, Decimal('0.00020010')) + self.assertEqual(order.price, Decimal('49975.00')) + + @patch('coinbase_advanced_trader.services.order_service.logger') + def test_log_order_result(self, mock_logger): + """Test the _log_order_result method.""" + order_response = { + 'success': True, + 'order_id': 'fb67bb54-73ba-41ec-a038-9883664325b7', + 'success_response': { + 'order_id': 'fb67bb54-73ba-41ec-a038-9883664325b7', + 'product_id': 'BTC-USDC', + 'side': 'BUY', + 'client_order_id': '12345678901' + }, + 'order_configuration': { + 'limit_limit_gtc': { + 'base_size': '0.00020010', + 'limit_price': '49975.00', + 'post_only': False + } + } + } + product_id = "BTC-USDC" + amount = Decimal('0.00020010') + price = Decimal('49975.00') + side = OrderSide.BUY + + self.order_service._log_order_result( + order_response, product_id, amount, price, side + ) + + mock_logger.info.assert_called_once_with( + "Successfully placed a limit buy order for 0.00020010 BTC ($10.00) " + "at a price of 49975.00 USDC." + ) + mock_logger.debug.assert_called_once_with(f"Coinbase response: {order_response}") + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/coinbase_advanced_trader/tests/test_price_service.py b/coinbase_advanced_trader/tests/test_price_service.py index e69de29..f8f872c 100644 --- a/coinbase_advanced_trader/tests/test_price_service.py +++ b/coinbase_advanced_trader/tests/test_price_service.py @@ -0,0 +1,84 @@ +import unittest +from unittest.mock import Mock, patch +from decimal import Decimal + +from coinbase.rest import RESTClient + +from coinbase_advanced_trader.services.price_service import PriceService + + +class TestPriceService(unittest.TestCase): + """Test cases for the PriceService class.""" + + def setUp(self): + """Set up the test environment before each test method.""" + self.rest_client_mock = Mock(spec=RESTClient) + self.price_service = PriceService(self.rest_client_mock) + + def test_get_spot_price_success(self): + """Test successful retrieval of spot price.""" + product_id = "BTC-USDC" + mock_response = { + 'product_id': 'BTC-USDC', + 'price': '61536', + 'quote_increment': '0.01' + } + self.rest_client_mock.get_product.return_value = mock_response + + result = self.price_service.get_spot_price(product_id) + + self.rest_client_mock.get_product.assert_called_once_with(product_id) + self.assertEqual(result, Decimal('61536.00')) + + def test_get_spot_price_missing_price(self): + """Test handling of missing price in API response.""" + product_id = "BTC-USDC" + mock_response = { + 'product_id': 'BTC-USDC', + 'quote_increment': '0.01' + } + self.rest_client_mock.get_product.return_value = mock_response + + result = self.price_service.get_spot_price(product_id) + + self.rest_client_mock.get_product.assert_called_once_with(product_id) + self.assertIsNone(result) + + @patch('coinbase_advanced_trader.services.price_service.logger') + def test_get_spot_price_exception(self, mock_logger): + """Test error handling when fetching spot price.""" + product_id = "BTC-USDDC" + self.rest_client_mock.get_product.side_effect = Exception( + "Product not found" + ) + + result = self.price_service.get_spot_price(product_id) + + self.rest_client_mock.get_product.assert_called_once_with(product_id) + self.assertIsNone(result) + mock_logger.error.assert_called_once_with( + "Error fetching spot price for BTC-USDDC: Product not found" + ) + + def test_get_product_details_success(self): + """Test successful retrieval of product details.""" + product_id = "BTC-USDC" + mock_response = { + 'product_id': 'BTC-USDC', + 'base_increment': '0.00000001', + 'quote_increment': '0.01' + } + self.rest_client_mock.get_product.return_value = mock_response + + result = self.price_service.get_product_details(product_id) + + self.rest_client_mock.get_product.assert_called_once_with(product_id) + expected_result = { + 'base_increment': Decimal('0.00000001'), + 'quote_increment': Decimal('0.01') + } + self.assertEqual(result, expected_result) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/coinbase_advanced_trader/tests/test_trading_config.py b/coinbase_advanced_trader/tests/test_trading_config.py new file mode 100644 index 0000000..a016123 --- /dev/null +++ b/coinbase_advanced_trader/tests/test_trading_config.py @@ -0,0 +1,34 @@ +import unittest +from coinbase_advanced_trader.trading_config import TradingConfig + + +class TestTradingConfig(unittest.TestCase): + """Test cases for the TradingConfig class.""" + + def setUp(self): + """Set up the test environment before each test method.""" + self.config = TradingConfig() + + def test_fgi_schedule_initial_state(self): + """Test the initial state of the Fear and Greed Index schedule.""" + initial_schedule = self.config.get_fgi_schedule() + self.assertEqual(len(initial_schedule), 9) + + def test_update_fgi_schedule_valid(self): + """Test updating the FGI schedule with valid data.""" + new_schedule = [{'threshold': 10, 'factor': 1.5, 'action': 'buy'}] + self.assertTrue(self.config.validate_schedule(new_schedule)) + self.config.update_fgi_schedule(new_schedule) + self.assertEqual(self.config.get_fgi_schedule(), new_schedule) + + def test_update_fgi_schedule_invalid(self): + """Test updating the FGI schedule with invalid data.""" + invalid_schedule = [ + {'threshold': 10, 'factor': 1.5, 'action': 'invalid_action'} + ] + with self.assertRaises(ValueError): + self.config.update_fgi_schedule(invalid_schedule) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/coinbase_advanced_trader/trading_config.py b/coinbase_advanced_trader/trading_config.py new file mode 100644 index 0000000..e81eec1 --- /dev/null +++ b/coinbase_advanced_trader/trading_config.py @@ -0,0 +1,71 @@ +from coinbase_advanced_trader.config import config_manager +from coinbase_advanced_trader.logger import logger + +BUY_PRICE_MULTIPLIER = config_manager.get('BUY_PRICE_MULTIPLIER') +SELL_PRICE_MULTIPLIER = config_manager.get('SELL_PRICE_MULTIPLIER') +FEAR_AND_GREED_API_URL = config_manager.get('FEAR_AND_GREED_API_URL') + +class TradingConfig: + def __init__(self): + self._fgi_schedule = [ + {'threshold': 10, 'factor': 1.5, 'action': 'buy'}, + {'threshold': 20, 'factor': 1.3, 'action': 'buy'}, + {'threshold': 30, 'factor': 1.1, 'action': 'buy'}, + {'threshold': 40, 'factor': 1.0, 'action': 'buy'}, + {'threshold': 50, 'factor': 0.9, 'action': 'buy'}, + {'threshold': 60, 'factor': 0.7, 'action': 'buy'}, + {'threshold': 70, 'factor': 1, 'action': 'sell'}, + {'threshold': 80, 'factor': 1.5, 'action': 'sell'}, + {'threshold': 90, 'factor': 2, 'action': 'sell'} + ] + + def update_fgi_schedule(self, new_schedule): + if self.validate_schedule(new_schedule): + self._fgi_schedule = new_schedule + logger.info("FGI schedule updated.") + else: + logger.error("Invalid FGI schedule. Update rejected.") + raise ValueError("Invalid FGI schedule") + + def get_fgi_schedule(self): + return self._fgi_schedule + + def validate_schedule(self, schedule): + """ + Validate the given FGI schedule without updating it. + + Args: + schedule (List[Dict]): The schedule to validate. + + Returns: + bool: True if the schedule is valid, False otherwise. + """ + if not schedule: + logger.warning("Empty schedule provided.") + return False + + last_buy_threshold = float('-inf') + last_sell_threshold = float('inf') + + for condition in sorted(schedule, key=lambda x: x['threshold']): + if 'threshold' not in condition or 'factor' not in condition or 'action' not in condition: + logger.warning(f"Invalid condition format: {condition}") + return False + + if condition['action'] == 'buy': + if condition['threshold'] >= last_sell_threshold: + logger.warning(f"Invalid buy threshold: {condition['threshold']}") + return False + last_buy_threshold = condition['threshold'] + elif condition['action'] == 'sell': + if condition['threshold'] <= last_buy_threshold: + logger.warning(f"Invalid sell threshold: {condition['threshold']}") + return False + last_sell_threshold = condition['threshold'] + else: + logger.warning(f"Invalid action: {condition['action']}") + return False + + logger.info("FGI schedule is valid.") + return True + \ No newline at end of file diff --git a/coinbase_advanced_trader/utils/__init__.py b/coinbase_advanced_trader/utils/__init__.py index b778d97..b59c7b9 100644 --- a/coinbase_advanced_trader/utils/__init__.py +++ b/coinbase_advanced_trader/utils/__init__.py @@ -1,3 +1,5 @@ +"""Utility functions for the Coinbase Advanced Trader application.""" + from .helpers import calculate_base_size, generate_client_order_id __all__ = ['calculate_base_size', 'generate_client_order_id'] \ No newline at end of file diff --git a/coinbase_advanced_trader/utils/helpers.py b/coinbase_advanced_trader/utils/helpers.py index c2ed5bd..8d424eb 100644 --- a/coinbase_advanced_trader/utils/helpers.py +++ b/coinbase_advanced_trader/utils/helpers.py @@ -2,7 +2,11 @@ from decimal import Decimal, ROUND_HALF_UP -def calculate_base_size(fiat_amount: Decimal, spot_price: Decimal, base_increment: Decimal) -> Decimal: +def calculate_base_size( + fiat_amount: Decimal, + spot_price: Decimal, + base_increment: Decimal +) -> Decimal: """ Calculate the base size for an order. @@ -14,7 +18,10 @@ def calculate_base_size(fiat_amount: Decimal, spot_price: Decimal, base_incremen Returns: Decimal: The calculated base size. """ - return (fiat_amount / spot_price).quantize(base_increment, rounding=ROUND_HALF_UP) + return (fiat_amount / spot_price).quantize( + base_increment, rounding=ROUND_HALF_UP + ) + def generate_client_order_id() -> str: """ From 5ede2f52dba689aa8e3b9f4d885eadbe9265faa5 Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Fri, 5 Jul 2024 16:27:10 -0500 Subject: [PATCH 15/23] Updated tests --- coinbase_advanced_trader/config.py | 20 +++++-- coinbase_advanced_trader/constants.py | 2 + .../enhanced_rest_client.py | 38 ++++++------- coinbase_advanced_trader/logger.py | 24 ++++++--- .../tests/test_enhanced_rest_client.py | 12 +++-- .../tests/test_fear_and_greed_strategy.py | 18 ++++--- .../tests/test_trading_config.py | 4 +- coinbase_advanced_trader/trading_config.py | 53 +++++++++++++------ 8 files changed, 112 insertions(+), 59 deletions(-) diff --git a/coinbase_advanced_trader/config.py b/coinbase_advanced_trader/config.py index 9640a52..9eb8e7d 100644 --- a/coinbase_advanced_trader/config.py +++ b/coinbase_advanced_trader/config.py @@ -1,21 +1,32 @@ -import yaml +"""Configuration management for Coinbase Advanced Trader.""" + +import logging from pathlib import Path + +import yaml + from coinbase_advanced_trader.constants import DEFAULT_CONFIG + class ConfigManager: + """Singleton class for managing application configuration.""" + _instance = None def __new__(cls): + """Create a new instance if one doesn't exist, otherwise return the existing instance.""" if cls._instance is None: cls._instance = super().__new__(cls) cls._instance.initialize() return cls._instance def initialize(self): + """Initialize the ConfigManager with default configuration and user overrides.""" self.config_path = Path('config.yaml') self.config = self._load_config() def _load_config(self): + """Load configuration from file, falling back to defaults if necessary.""" config = DEFAULT_CONFIG.copy() if self.config_path.exists(): try: @@ -24,14 +35,17 @@ def _load_config(self): if user_config: config.update(user_config) except Exception as e: - print(f"Error loading user config: {e}") + logging.error(f"Error loading user config: {e}") return config def get(self, key, default=None): + """Retrieve a configuration value by key, with an optional default.""" return self.config.get(key, default) - + @classmethod def reset(cls): + """Reset the singleton instance.""" cls._instance = None + config_manager = ConfigManager() \ No newline at end of file diff --git a/coinbase_advanced_trader/constants.py b/coinbase_advanced_trader/constants.py index 352b0db..5e08042 100644 --- a/coinbase_advanced_trader/constants.py +++ b/coinbase_advanced_trader/constants.py @@ -1,3 +1,5 @@ +"""Constants and default configuration for the Coinbase Advanced Trader.""" + DEFAULT_CONFIG = { 'BUY_PRICE_MULTIPLIER': 0.9995, 'SELL_PRICE_MULTIPLIER': 1.005, diff --git a/coinbase_advanced_trader/enhanced_rest_client.py b/coinbase_advanced_trader/enhanced_rest_client.py index a9b1b1c..e499125 100644 --- a/coinbase_advanced_trader/enhanced_rest_client.py +++ b/coinbase_advanced_trader/enhanced_rest_client.py @@ -5,8 +5,8 @@ from .services.order_service import OrderService from .services.fear_and_greed_strategy import FearAndGreedStrategy from .services.price_service import PriceService -from coinbase_advanced_trader.trading_config import BUY_PRICE_MULTIPLIER, SELL_PRICE_MULTIPLIER -from .trading_config import TradingConfig +from .trading_config import FearAndGreedConfig +from coinbase_advanced_trader.constants import DEFAULT_CONFIG from coinbase_advanced_trader.logger import logger @@ -25,24 +25,24 @@ def __init__(self, api_key: str, api_secret: str, **kwargs: Any) -> None: super().__init__(api_key=api_key, api_secret=api_secret, **kwargs) self._price_service = PriceService(self) self._order_service = OrderService(self, self._price_service) - self._config = TradingConfig() + self._config = FearAndGreedConfig() self._fear_and_greed_strategy = FearAndGreedStrategy( self._order_service, self._price_service, self._config ) - def update_fgi_schedule(self, new_schedule): + def update_fgi_schedule(self, new_schedule: List[Dict[str, Any]]) -> bool: """ Update the Fear and Greed Index trading schedule. Args: - new_schedule (List[Dict]): The new schedule to be set. - - Raises: - ValueError: If the provided schedule is invalid. + new_schedule: The new schedule to be set. Returns: bool: True if the schedule was successfully updated, False otherwise. + Raises: + ValueError: If the provided schedule is invalid. + Example: >>> client = EnhancedRESTClient(api_key, api_secret) >>> new_schedule = [ @@ -52,14 +52,14 @@ def update_fgi_schedule(self, new_schedule): >>> client.update_fgi_schedule(new_schedule) True """ + if not self._config.validate_schedule(new_schedule): + logger.warning("Invalid FGI schedule provided. Update rejected.") + return False + try: - if self._config.validate_schedule(new_schedule): - self._config.update_fgi_schedule(new_schedule) - logger.info("FGI schedule successfully updated.") - return True - else: - logger.warning("Invalid FGI schedule provided. Update rejected.") - return False + self._config.update_fgi_schedule(new_schedule) + logger.info("FGI schedule successfully updated.") + return True except ValueError as e: logger.error(f"Failed to update FGI schedule: {str(e)}") raise @@ -78,7 +78,7 @@ def validate_fgi_schedule(self, schedule: List[Dict[str, Any]]) -> bool: Validate a Fear and Greed Index trading schedule without updating it. Args: - schedule (List[Dict[str, Any]]): The schedule to validate. + schedule: The schedule to validate. Returns: bool: True if the schedule is valid, False otherwise. @@ -124,7 +124,7 @@ def fiat_limit_buy( self, product_id: str, fiat_amount: str, - price_multiplier: float = BUY_PRICE_MULTIPLIER + price_multiplier: float = DEFAULT_CONFIG['BUY_PRICE_MULTIPLIER'] ) -> Dict[str, Any]: """ Place a limit buy order with fiat currency. @@ -145,7 +145,7 @@ def fiat_limit_sell( self, product_id: str, fiat_amount: str, - price_multiplier: float = SELL_PRICE_MULTIPLIER + price_multiplier: float = DEFAULT_CONFIG['SELL_PRICE_MULTIPLIER'] ) -> Dict[str, Any]: """ Place a limit sell order with fiat currency. @@ -166,7 +166,7 @@ def trade_based_on_fgi( self, product_id: str, fiat_amount: str, - schedule: Optional[str] = None + schedule: Optional[List[Dict[str, Any]]] = None ) -> Dict[str, Any]: """ Execute a complex trade based on the Fear and Greed Index. diff --git a/coinbase_advanced_trader/logger.py b/coinbase_advanced_trader/logger.py index 27f8489..21c811b 100644 --- a/coinbase_advanced_trader/logger.py +++ b/coinbase_advanced_trader/logger.py @@ -2,23 +2,34 @@ import sys from coinbase_advanced_trader.config import config_manager + def setup_logger(): - LOG_FILE_PATH = config_manager.get('LOG_FILE_PATH') - LOG_LEVEL = config_manager.get('LOG_LEVEL') + """ + Set up and configure the logger for the Coinbase Advanced Trader application. + + Returns: + logging.Logger: Configured logger instance. + """ + log_file_path = config_manager.get('LOG_FILE_PATH') + log_level = config_manager.get('LOG_LEVEL') logger = logging.getLogger('coinbase_advanced_trader') - logger.setLevel(getattr(logging, LOG_LEVEL)) + logger.setLevel(getattr(logging, log_level)) # Console handler console_handler = logging.StreamHandler(sys.stdout) console_handler.setLevel(logging.INFO) - console_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') + console_formatter = logging.Formatter( + '%(asctime)s - %(levelname)s - %(message)s' + ) console_handler.setFormatter(console_formatter) # File handler - file_handler = logging.FileHandler(LOG_FILE_PATH) + file_handler = logging.FileHandler(log_file_path) file_handler.setLevel(logging.DEBUG) - file_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s') + file_formatter = logging.Formatter( + '%(asctime)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s' + ) file_handler.setFormatter(file_formatter) # Add handlers to logger @@ -27,4 +38,5 @@ def setup_logger(): return logger + logger = setup_logger() \ No newline at end of file diff --git a/coinbase_advanced_trader/tests/test_enhanced_rest_client.py b/coinbase_advanced_trader/tests/test_enhanced_rest_client.py index d2fa8ae..f4a9727 100644 --- a/coinbase_advanced_trader/tests/test_enhanced_rest_client.py +++ b/coinbase_advanced_trader/tests/test_enhanced_rest_client.py @@ -7,7 +7,7 @@ from coinbase_advanced_trader.services.order_service import OrderService from coinbase_advanced_trader.services.price_service import PriceService from coinbase_advanced_trader.services.fear_and_greed_strategy import FearAndGreedStrategy -from coinbase_advanced_trader.trading_config import TradingConfig +from coinbase_advanced_trader.trading_config import FearAndGreedConfig class TestEnhancedRESTClient(unittest.TestCase): @@ -21,7 +21,7 @@ def setUp(self): self.client._order_service = Mock(spec=OrderService) self.client._price_service = Mock(spec=PriceService) self.client._fear_and_greed_strategy = Mock(spec=FearAndGreedStrategy) - self.client._config = Mock(spec=TradingConfig) + self.client._config = Mock(spec=FearAndGreedConfig) def test_fiat_market_buy(self): """Test the fiat_market_buy method.""" @@ -120,9 +120,11 @@ def test_trade_based_on_fgi(self): result = self.client.trade_based_on_fgi(product_id, fiat_amount) - self.client._fear_and_greed_strategy.execute_trade.assert_called_once_with( - product_id, fiat_amount - ) + self.client._fear_and_greed_strategy.execute_trade.assert_called_once() + + call_args = self.client._fear_and_greed_strategy.execute_trade.call_args + self.assertEqual(call_args[0][0], product_id) + self.assertAlmostEqual(Decimal(call_args[0][1]), Decimal(fiat_amount), places=8) self.assertEqual(result, mock_result) def test_update_fgi_schedule(self): diff --git a/coinbase_advanced_trader/tests/test_fear_and_greed_strategy.py b/coinbase_advanced_trader/tests/test_fear_and_greed_strategy.py index fe27d64..72738a3 100644 --- a/coinbase_advanced_trader/tests/test_fear_and_greed_strategy.py +++ b/coinbase_advanced_trader/tests/test_fear_and_greed_strategy.py @@ -6,7 +6,7 @@ from coinbase_advanced_trader.models import Order, OrderSide, OrderType from coinbase_advanced_trader.services.order_service import OrderService from coinbase_advanced_trader.services.price_service import PriceService -from coinbase_advanced_trader.trading_config import TradingConfig +from coinbase_advanced_trader.trading_config import FearAndGreedConfig class TestFearAndGreedStrategy(unittest.TestCase): @@ -16,7 +16,7 @@ def setUp(self): """Set up the test environment before each test method.""" self.order_service_mock = Mock(spec=OrderService) self.price_service_mock = Mock(spec=PriceService) - self.config_mock = Mock(spec=TradingConfig) + self.config_mock = Mock(spec=FearAndGreedConfig) self.strategy = FearAndGreedStrategy( self.order_service_mock, self.price_service_mock, @@ -48,9 +48,10 @@ def test_execute_trade_buy(self, mock_get): result = self.strategy.execute_trade('BTC-USDC', '10') self.assertEqual(result, mock_order) - self.order_service_mock.fiat_limit_buy.assert_called_once_with( - 'BTC-USDC', '12.00' - ) + self.order_service_mock.fiat_limit_buy.assert_called_once() + call_args = self.order_service_mock.fiat_limit_buy.call_args + self.assertEqual(call_args[0][0], 'BTC-USDC') + self.assertAlmostEqual(Decimal(call_args[0][1]), Decimal('12.00'), places=8) mock_get.assert_called_once() self.config_mock.get_fgi_schedule.assert_called_once() @@ -79,9 +80,10 @@ def test_execute_trade_sell(self, mock_get): result = self.strategy.execute_trade('BTC-USDC', '10') self.assertEqual(result, mock_order) - self.order_service_mock.fiat_limit_sell.assert_called_once_with( - 'BTC-USDC', '8.00' - ) + self.order_service_mock.fiat_limit_sell.assert_called_once() + call_args = self.order_service_mock.fiat_limit_sell.call_args + self.assertEqual(call_args[0][0], 'BTC-USDC') + self.assertAlmostEqual(Decimal(call_args[0][1]), Decimal('8.00'), places=8) mock_get.assert_called_once() self.config_mock.get_fgi_schedule.assert_called_once() diff --git a/coinbase_advanced_trader/tests/test_trading_config.py b/coinbase_advanced_trader/tests/test_trading_config.py index a016123..9c52a3d 100644 --- a/coinbase_advanced_trader/tests/test_trading_config.py +++ b/coinbase_advanced_trader/tests/test_trading_config.py @@ -1,5 +1,5 @@ import unittest -from coinbase_advanced_trader.trading_config import TradingConfig +from coinbase_advanced_trader.trading_config import FearAndGreedConfig class TestTradingConfig(unittest.TestCase): @@ -7,7 +7,7 @@ class TestTradingConfig(unittest.TestCase): def setUp(self): """Set up the test environment before each test method.""" - self.config = TradingConfig() + self.config = FearAndGreedConfig() def test_fgi_schedule_initial_state(self): """Test the initial state of the Fear and Greed Index schedule.""" diff --git a/coinbase_advanced_trader/trading_config.py b/coinbase_advanced_trader/trading_config.py index e81eec1..aebabeb 100644 --- a/coinbase_advanced_trader/trading_config.py +++ b/coinbase_advanced_trader/trading_config.py @@ -1,3 +1,6 @@ +"""Trading configuration module for Coinbase Advanced Trader.""" + +from typing import List, Dict, Any from coinbase_advanced_trader.config import config_manager from coinbase_advanced_trader.logger import logger @@ -5,21 +8,34 @@ SELL_PRICE_MULTIPLIER = config_manager.get('SELL_PRICE_MULTIPLIER') FEAR_AND_GREED_API_URL = config_manager.get('FEAR_AND_GREED_API_URL') -class TradingConfig: - def __init__(self): - self._fgi_schedule = [ + +class FearAndGreedConfig: + """Manages trading configuration and Fear and Greed Index (FGI) schedule.""" + + def __init__(self) -> None: + """Initialize TradingConfig with default FGI schedule.""" + self._fgi_schedule: List[Dict[str, Any]] = [ {'threshold': 10, 'factor': 1.5, 'action': 'buy'}, {'threshold': 20, 'factor': 1.3, 'action': 'buy'}, {'threshold': 30, 'factor': 1.1, 'action': 'buy'}, {'threshold': 40, 'factor': 1.0, 'action': 'buy'}, {'threshold': 50, 'factor': 0.9, 'action': 'buy'}, {'threshold': 60, 'factor': 0.7, 'action': 'buy'}, - {'threshold': 70, 'factor': 1, 'action': 'sell'}, + {'threshold': 70, 'factor': 1.0, 'action': 'sell'}, {'threshold': 80, 'factor': 1.5, 'action': 'sell'}, - {'threshold': 90, 'factor': 2, 'action': 'sell'} + {'threshold': 90, 'factor': 2.0, 'action': 'sell'} ] - def update_fgi_schedule(self, new_schedule): + def update_fgi_schedule(self, new_schedule: List[Dict[str, Any]]) -> None: + """ + Update the FGI schedule if valid. + + Args: + new_schedule: The new schedule to be set. + + Raises: + ValueError: If the provided schedule is invalid. + """ if self.validate_schedule(new_schedule): self._fgi_schedule = new_schedule logger.info("FGI schedule updated.") @@ -27,31 +43,37 @@ def update_fgi_schedule(self, new_schedule): logger.error("Invalid FGI schedule. Update rejected.") raise ValueError("Invalid FGI schedule") - def get_fgi_schedule(self): + def get_fgi_schedule(self) -> List[Dict[str, Any]]: + """ + Get the current FGI schedule. + + Returns: + The current FGI schedule. + """ return self._fgi_schedule - def validate_schedule(self, schedule): + def validate_schedule(self, schedule: List[Dict[str, Any]]) -> bool: """ Validate the given FGI schedule without updating it. Args: - schedule (List[Dict]): The schedule to validate. + schedule: The schedule to validate. Returns: - bool: True if the schedule is valid, False otherwise. + True if the schedule is valid, False otherwise. """ if not schedule: logger.warning("Empty schedule provided.") return False - + last_buy_threshold = float('-inf') last_sell_threshold = float('inf') - + for condition in sorted(schedule, key=lambda x: x['threshold']): - if 'threshold' not in condition or 'factor' not in condition or 'action' not in condition: + if not all(key in condition for key in ('threshold', 'factor', 'action')): logger.warning(f"Invalid condition format: {condition}") return False - + if condition['action'] == 'buy': if condition['threshold'] >= last_sell_threshold: logger.warning(f"Invalid buy threshold: {condition['threshold']}") @@ -65,7 +87,6 @@ def validate_schedule(self, schedule): else: logger.warning(f"Invalid action: {condition['action']}") return False - + logger.info("FGI schedule is valid.") return True - \ No newline at end of file From 1edad582d708f7965a86761570684581303aaa8f Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Fri, 5 Jul 2024 21:05:08 -0500 Subject: [PATCH 16/23] Update LICENSE --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 0ab2062..df015f5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2023 Rhett Reisman +Copyright (c) 2024 Rhett Reisman Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 0e6b46759e63d24c65fe28809ee9386c027f516d Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Sat, 6 Jul 2024 16:07:49 -0500 Subject: [PATCH 17/23] Support for legacy authentication --- coinbase_advanced_trader/legacy/__init__.py | 0 coinbase_advanced_trader/legacy/cb_auth.py | 104 ++++ .../legacy/coinbase_client.py | 231 +++++++ .../legacy/legacy_config.py | 52 ++ .../legacy/strategies/__init__.py | 0 .../strategies/fear_and_greed_strategies.py | 80 +++ .../strategies/limit_order_strategies.py | 154 +++++ .../strategies/market_order_strategies.py | 55 ++ .../legacy/strategies/utils.py | 37 ++ .../legacy/tests/__init__.py | 0 .../tests/test_fear_and_greed_strategies.py | 33 + .../tests/test_limit_order_strategies.py | 43 ++ .../tests/test_market_order_strategies.py | 43 ++ .../legacy/tests/test_utils.py | 42 ++ .../legacy_tests/__init__.py | 0 .../legacy_tests/test_coinbase_client.py | 572 ++++++++++++++++++ 16 files changed, 1446 insertions(+) create mode 100644 coinbase_advanced_trader/legacy/__init__.py create mode 100644 coinbase_advanced_trader/legacy/cb_auth.py create mode 100644 coinbase_advanced_trader/legacy/coinbase_client.py create mode 100644 coinbase_advanced_trader/legacy/legacy_config.py create mode 100644 coinbase_advanced_trader/legacy/strategies/__init__.py create mode 100644 coinbase_advanced_trader/legacy/strategies/fear_and_greed_strategies.py create mode 100644 coinbase_advanced_trader/legacy/strategies/limit_order_strategies.py create mode 100644 coinbase_advanced_trader/legacy/strategies/market_order_strategies.py create mode 100644 coinbase_advanced_trader/legacy/strategies/utils.py create mode 100644 coinbase_advanced_trader/legacy/tests/__init__.py create mode 100644 coinbase_advanced_trader/legacy/tests/test_fear_and_greed_strategies.py create mode 100644 coinbase_advanced_trader/legacy/tests/test_limit_order_strategies.py create mode 100644 coinbase_advanced_trader/legacy/tests/test_market_order_strategies.py create mode 100644 coinbase_advanced_trader/legacy/tests/test_utils.py create mode 100644 coinbase_advanced_trader/legacy_tests/__init__.py create mode 100644 coinbase_advanced_trader/legacy_tests/test_coinbase_client.py diff --git a/coinbase_advanced_trader/legacy/__init__.py b/coinbase_advanced_trader/legacy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/coinbase_advanced_trader/legacy/cb_auth.py b/coinbase_advanced_trader/legacy/cb_auth.py new file mode 100644 index 0000000..3b75ba3 --- /dev/null +++ b/coinbase_advanced_trader/legacy/cb_auth.py @@ -0,0 +1,104 @@ +import http.client +import hmac +import hashlib +import json +import time +from urllib.parse import urlencode +from typing import Union, Dict + + +class CBAuth: + """ + Singleton class for Coinbase authentication. + """ + + _instance = None # Class attribute to hold the singleton instance + + def __new__(cls): + """ + Override the __new__ method to control the object creation process. + :return: A single instance of CBAuth + """ + if cls._instance is None: + print("Authenticating with Coinbase") + cls._instance = super(CBAuth, cls).__new__(cls) + cls._instance.init() + return cls._instance + + def init(self): + """ + Initialize the CBAuth instance with API credentials. + """ + self.key = None + self.secret = None + + def set_credentials(self, api_key, api_secret): + """ + Update the API credentials used for authentication. + :param api_key: The API Key for Coinbase API + :param api_secret: The API Secret for Coinbase API + """ + self.key = api_key + self.secret = api_secret + + def __call__(self, method: str, path: str, body: Union[Dict, str] = '', params: Dict[str, str] = None) -> Dict: + """ + Prepare and send an authenticated request to the Coinbase API. + + :param method: HTTP method (e.g., 'GET', 'POST') + :param path: API endpoint path + :param body: Request payload + :param params: URL parameters + :return: Response from the Coinbase API as a dictionary + """ + path = self.add_query_params(path, params) + body_encoded = self.prepare_body(body) + headers = self.create_headers(method, path, body) + return self.send_request(method, path, body_encoded, headers) + + def add_query_params(self, path, params): + if params: + query_params = urlencode(params) + path = f'{path}?{query_params}' + return path + + def prepare_body(self, body): + return json.dumps(body).encode('utf-8') if body else b'' + + def create_headers(self, method, path, body): + timestamp = str(int(time.time())) + message = timestamp + method.upper() + \ + path.split('?')[0] + (json.dumps(body) if body else '') + signature = hmac.new(self.secret.encode( + 'utf-8'), message.encode('utf-8'), digestmod=hashlib.sha256).hexdigest() + + return { + "Content-Type": "application/json", + "CB-ACCESS-KEY": self.key, + "CB-ACCESS-SIGN": signature, + "CB-ACCESS-TIMESTAMP": timestamp + } + + def send_request(self, method, path, body_encoded, headers): + conn = http.client.HTTPSConnection("api.coinbase.com") + try: + conn.request(method, path, body_encoded, headers) + res = conn.getresponse() + data = res.read() + + if res.status == 401: + print("Error: Unauthorized. Please check your API key and secret.") + return None + + response_data = json.loads(data.decode("utf-8")) + if 'error_details' in response_data and response_data['error_details'] == 'missing required scopes': + print( + "Error: Missing Required Scopes. Please update your API Keys to include more permissions.") + return None + + return response_data + except json.JSONDecodeError: + print("Error: Unable to decode JSON response. Raw response data:", data) + return None + finally: + conn.close() diff --git a/coinbase_advanced_trader/legacy/coinbase_client.py b/coinbase_advanced_trader/legacy/coinbase_client.py new file mode 100644 index 0000000..6832dc9 --- /dev/null +++ b/coinbase_advanced_trader/legacy/coinbase_client.py @@ -0,0 +1,231 @@ +from enum import Enum +import uuid +import json +from coinbase_advanced_trader.legacy.cb_auth import CBAuth + +# Initialize the single instance of CBAuth +cb_auth = CBAuth() + + +class Side(Enum): + BUY = 1 + SELL = 0 + + +class Method(Enum): + POST = "POST" + GET = "GET" + + +def generate_client_order_id(): + return str(uuid.uuid4()) + + +def listAccounts(limit=49, cursor=None): + """ + Get a list of authenticated accounts for the current user. + + This function uses the GET method to retrieve a list of authenticated accounts from the Coinbase Advanced Trade API. + + :param limit: A pagination limit with default of 49 and maximum of 250. If has_next is true, additional orders are available to be fetched with pagination and the cursor value in the response can be passed as cursor parameter in the subsequent request. + :param cursor: Cursor used for pagination. When provided, the response returns responses after this cursor. + :return: A dictionary containing the response from the server. A successful response will return a 200 status code. An unexpected error will return a default error response. + """ + return cb_auth(Method.GET.value, '/api/v3/brokerage/accounts', {'limit': limit, 'cursor': cursor}) + + +def getAccount(account_uuid): + """ + Get a list of information about an account, given an account UUID. + + This function uses the GET method to retrieve information about an account from the Coinbase Advanced Trade API. + + :param account_uuid: The account's UUID. Use listAccounts() to find account UUIDs. + :return: A dictionary containing the response from the server. A successful response will return a 200 status code. An unexpected error will return a default error response. + """ + return cb_auth(Method.GET.value, f'/api/v3/brokerage/accounts/{account_uuid}') + + +def createOrder(client_order_id, product_id, side, order_type, order_configuration): + """ + Create an order with the given parameters. + + :param client_order_id: A unique ID generated by the client for this order. + :param product_id: The ID of the product to order. + :param side: The side of the order (e.g., 'buy' or 'sell'). + :param order_type: The type of order (e.g., 'limit_limit_gtc'). + :param order_configuration: A dictionary containing order details such as price, size, and post_only. + :return: A dictionary containing the response from the server. + """ + payload = { + "client_order_id": client_order_id, + "product_id": product_id, + "side": side, + "order_configuration": { + order_type: order_configuration + } + } + # print("Payload being sent to server:", payload) # For debugging + return cb_auth(Method.POST.value, '/api/v3/brokerage/orders', payload) + + +def cancelOrders(order_ids): + """ + Initiate cancel requests for one or more orders. + + This function uses the POST method to initiate cancel requests for one or more orders on the Coinbase Advanced Trade API. + + :param order_ids: A list of order IDs for which cancel requests should be initiated. + :return: A dictionary containing the response from the server. A successful response will return a 200 status code. An unexpected error will return a default error response. + """ + body = json.dumps({"order_ids": order_ids}) + return cb_auth(Method.POST.value, '/api/v3/brokerage/orders/batch_cancel', body) + + +def listOrders(**kwargs): + """ + Retrieve a list of historical orders. + + This function uses the GET method to retrieve a list of historical orders from the Coinbase Advanced Trade API. + The orders are returned in a batch format. + + :param kwargs: Optional parameters that can be passed to the API. These can include: + 'product_id': Optional string of the product ID. Defaults to null, or fetch for all products. + 'order_status': A list of order statuses. + 'limit': A pagination limit with no default set. + 'start_date': Start date to fetch orders from, inclusive. + 'end_date': An optional end date for the query window, exclusive. + 'user_native_currency': (Deprecated) String of the users native currency. Default is `USD`. + 'order_type': Type of orders to return. Default is to return all order types. + 'order_side': Only orders matching this side are returned. Default is to return all sides. + 'cursor': Cursor used for pagination. + 'product_type': Only orders matching this product type are returned. Default is to return all product types. + 'order_placement_source': Only orders matching this placement source are returned. Default is to return RETAIL_ADVANCED placement source. + 'contract_expiry_type': Only orders matching this contract expiry type are returned. Filter is only applied if ProductType is set to FUTURE in the request. + :return: A dictionary containing the response from the server. This will include details about each order, such as the order ID, product ID, side, type, and status. + """ + return cb_auth(Method.GET.value, '/api/v3/brokerage/orders/historical/batch', params=kwargs) + + +def listFills(**kwargs): + """ + Retrieve a list of fills filtered by optional query parameters. + + This function uses the GET method to retrieve a list of fills from the Coinbase Advanced Trade API. + The fills are returned in a batch format. + + :param kwargs: Optional parameters that can be passed to the API. These can include: + 'order_id': Optional string of the order ID. + 'product_id': Optional string of the product ID. + 'start_sequence_timestamp': Start date. Only fills with a trade time at or after this start date are returned. + 'end_sequence_timestamp': End date. Only fills with a trade time before this start date are returned. + 'limit': Maximum number of fills to return in response. Defaults to 100. + 'cursor': Cursor used for pagination. When provided, the response returns responses after this cursor. + :return: A dictionary containing the response from the server. This will include details about each fill, such as the fill ID, product ID, side, type, and status. + """ + return cb_auth(Method.GET.value, '/api/v3/brokerage/orders/historical/fills', params=kwargs) + + +def getOrder(order_id): + """ + Retrieve a single order by order ID. + + This function uses the GET method to retrieve a single order from the Coinbase Advanced Trade API. + + :param order_id: The ID of the order to retrieve. + :return: A dictionary containing the response from the server. This will include details about the order, such as the order ID, product ID, side, type, and status. + """ + return cb_auth(Method.GET.value, f'/api/v3/brokerage/orders/historical/{order_id}') + + +def listProducts(**kwargs): + """ + Get a list of the available currency pairs for trading. + + This function uses the GET method to retrieve a list of products from the Coinbase Advanced Trade API. + + :param limit: An optional integer describing how many products to return. Default is None. + :param offset: An optional integer describing the number of products to offset before returning. Default is None. + :param product_type: An optional string describing the type of products to return. Default is None. + :param product_ids: An optional list of strings describing the product IDs to return. Default is None. + :param contract_expiry_type: An optional string describing the contract expiry type. Default is 'UNKNOWN_CONTRACT_EXPIRY_TYPE'. + :return: A dictionary containing the response from the server. This will include details about each product, such as the product ID, product type, and contract expiry type. + """ + return cb_auth(Method.GET.value, '/api/v3/brokerage/products', params=kwargs) + + +def getProduct(product_id): + """ + Get information on a single product by product ID. + + This function uses the GET method to retrieve information about a single product from the Coinbase Advanced Trade API. + + :param product_id: The ID of the product to retrieve information for. + :return: A dictionary containing the response from the server. This will include details about the product, such as the product ID, product type, and contract expiry type. + """ + response = cb_auth( + Method.GET.value, f'/api/v3/brokerage/products/{product_id}') + + # Check if there's an error in the response + if 'error' in response and response['error'] == 'PERMISSION_DENIED': + print( + f"Error: {response['message']}. Details: {response['error_details']}") + return None + + return response + + +def getProductCandles(product_id, start, end, granularity): + """ + Get rates for a single product by product ID, grouped in buckets. + + This function uses the GET method to retrieve rates for a single product from the Coinbase Advanced Trade API. + + :param product_id: The trading pair. + :param start: Timestamp for starting range of aggregations, in UNIX time. + :param end: Timestamp for ending range of aggregations, in UNIX time. + :param granularity: The time slice value for each candle. + :return: A dictionary containing the response from the server. This will include details about each candle, such as the open, high, low, close, and volume. + """ + params = { + 'start': start, + 'end': end, + 'granularity': granularity + } + return cb_auth(Method.GET.value, f'/api/v3/brokerage/products/{product_id}/candles', params=params) + + +def getMarketTrades(product_id, limit): + """ + Get snapshot information, by product ID, about the last trades (ticks), best bid/ask, and 24h volume. + + This function uses the GET method to retrieve snapshot information about the last trades from the Coinbase Advanced Trade API. + + :param product_id: The trading pair, i.e., 'BTC-USD'. + :param limit: Number of trades to return. + :return: A dictionary containing the response from the server. This will include details about the last trades, such as the best bid/ask, and 24h volume. + """ + return cb_auth(Method.GET.value, f'/api/v3/brokerage/products/{product_id}/ticker', {'limit': limit}) + + +def getTransactionsSummary(start_date, end_date, user_native_currency='USD', product_type='SPOT', contract_expiry_type='UNKNOWN_CONTRACT_EXPIRY_TYPE'): + """ + Get a summary of transactions with fee tiers, total volume, and fees. + + This function uses the GET method to retrieve a summary of transactions from the Coinbase Advanced Trade API. + + :param start_date: The start date of the transactions to retrieve, in datetime format. + :param end_date: The end date of the transactions to retrieve, in datetime format. + :param user_native_currency: The user's native currency. Default is 'USD'. + :param product_type: The type of product. Default is 'SPOT'. + :param contract_expiry_type: Only orders matching this contract expiry type are returned. Only filters response if ProductType is set to 'FUTURE'. Default is 'UNKNOWN_CONTRACT_EXPIRY_TYPE'. + :return: A dictionary containing the response from the server. This will include details about each transaction, such as fee tiers, total volume, and fees. + """ + params = { + 'start_date': start_date.strftime('%Y-%m-%dT%H:%M:%SZ'), + 'end_date': end_date.strftime('%Y-%m-%dT%H:%M:%SZ'), + 'user_native_currency': user_native_currency, + 'product_type': product_type, + 'contract_expiry_type': contract_expiry_type + } + return cb_auth(Method.GET.value, '/api/v3/brokerage/transaction_summary', params) diff --git a/coinbase_advanced_trader/legacy/legacy_config.py b/coinbase_advanced_trader/legacy/legacy_config.py new file mode 100644 index 0000000..27a1968 --- /dev/null +++ b/coinbase_advanced_trader/legacy/legacy_config.py @@ -0,0 +1,52 @@ +import os +from coinbase_advanced_trader.legacy.cb_auth import CBAuth + +API_KEY = None +API_SECRET = None + +# Default price multipliers for limit orders +BUY_PRICE_MULTIPLIER = 0.995 +SELL_PRICE_MULTIPLIER = 1.005 + +# Default schedule for the trade_based_on_fgi_simple function +SIMPLE_SCHEDULE = [ + {'threshold': 20, 'factor': 1.2, 'action': 'buy'}, + {'threshold': 80, 'factor': 0.8, 'action': 'sell'} +] + +# Default schedule for the trade_based_on_fgi_pro function +PRO_SCHEDULE = [ + {'threshold': 10, 'factor': 1.5, 'action': 'buy'}, + {'threshold': 20, 'factor': 1.3, 'action': 'buy'}, + {'threshold': 30, 'factor': 1.1, 'action': 'buy'}, + {'threshold': 70, 'factor': 0.9, 'action': 'sell'}, + {'threshold': 80, 'factor': 0.7, 'action': 'sell'}, + {'threshold': 90, 'factor': 0.5, 'action': 'sell'} +] + + +def set_api_credentials(api_key=None, api_secret=None): + global API_KEY + global API_SECRET + + # Option 1: Use provided arguments + if api_key and api_secret: + API_KEY = api_key + API_SECRET = api_secret + + # Option 2: Use environment variables + elif 'COINBASE_API_KEY' in os.environ and 'COINBASE_API_SECRET' in os.environ: + API_KEY = os.environ['COINBASE_API_KEY'] + API_SECRET = os.environ['COINBASE_API_SECRET'] + + # Option 3: Load from a separate file (e.g., keys.txt) + else: + try: + with open('keys.txt', 'r') as f: + API_KEY = f.readline().strip() + API_SECRET = f.readline().strip() + except FileNotFoundError: + print("Error: API keys not found. Please set your API keys.") + + # Update the CBAuth singleton instance with the new credentials + CBAuth().set_credentials(API_KEY, API_SECRET) diff --git a/coinbase_advanced_trader/legacy/strategies/__init__.py b/coinbase_advanced_trader/legacy/strategies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/coinbase_advanced_trader/legacy/strategies/fear_and_greed_strategies.py b/coinbase_advanced_trader/legacy/strategies/fear_and_greed_strategies.py new file mode 100644 index 0000000..565dfe6 --- /dev/null +++ b/coinbase_advanced_trader/legacy/strategies/fear_and_greed_strategies.py @@ -0,0 +1,80 @@ +import requests +from coinbase_advanced_trader.legacy.strategies.limit_order_strategies import fiat_limit_buy, fiat_limit_sell +from ..legacy_config import SIMPLE_SCHEDULE, PRO_SCHEDULE + + +def get_fear_and_greed_index(): + """ + Fetches the latest Fear and Greed Index (FGI) values from the API. + + Returns: + tuple: A tuple containing the FGI value and its classification. + """ + response = requests.get('https://api.alternative.me/fng/?limit=1') + data = response.json()['data'][0] + return int(data['value']), data['value_classification'] + + +def trade_based_on_fgi_simple(product_id, fiat_amount, schedule=SIMPLE_SCHEDULE): + """ + Executes a trade based on the Fear and Greed Index (FGI) using a simple strategy. + + Args: + product_id (str): The ID of the product to trade. + fiat_amount (float): The amount of fiat currency to trade. + schedule (list, optional): The trading schedule. Defaults to SIMPLE_SCHEDULE. + + Returns: + dict: The response from the trade execution. + """ + + fgi, classification = get_fear_and_greed_index() + + # Use the provided schedule or the default one + schedule = schedule or SIMPLE_SCHEDULE + + # Sort the schedule by threshold in ascending order + schedule.sort(key=lambda x: x['threshold']) + + # Get the lower and higher threshold values + lower_threshold = schedule[0]['threshold'] + higher_threshold = schedule[-1]['threshold'] + + for condition in schedule: + if fgi <= condition['threshold']: + fiat_amount *= condition['factor'] + if condition['action'] == 'buy': + return fiat_limit_buy(product_id, fiat_amount) + elif lower_threshold < fgi < higher_threshold: + response = fiat_limit_buy(product_id, fiat_amount) + else: + response = fiat_limit_sell(product_id, fiat_amount) + return {**response, 'Fear and Greed Index': fgi, 'classification': classification} + + +def trade_based_on_fgi_pro(product_id, fiat_amount, schedule=PRO_SCHEDULE): + """ + Executes a trade based on the Fear and Greed Index (FGI) using a professional strategy. + + Args: + product_id (str): The ID of the product to trade. + fiat_amount (float): The amount of fiat currency to trade. + schedule (list, optional): The trading schedule. Defaults to PRO_SCHEDULE. + + Returns: + dict: The response from the trade execution. + """ + + fgi, classification = get_fear_and_greed_index() + + # Use the provided schedule or the default one + schedule = schedule or PRO_SCHEDULE + + for condition in schedule: + if fgi <= condition['threshold']: + fiat_amount *= condition['factor'] + if condition['action'] == 'buy': + response = fiat_limit_buy(product_id, fiat_amount) + else: + response = fiat_limit_sell(product_id, fiat_amount) + return {**response, 'Fear and Greed Index': fgi, 'classification': classification} diff --git a/coinbase_advanced_trader/legacy/strategies/limit_order_strategies.py b/coinbase_advanced_trader/legacy/strategies/limit_order_strategies.py new file mode 100644 index 0000000..1b4042e --- /dev/null +++ b/coinbase_advanced_trader/legacy/strategies/limit_order_strategies.py @@ -0,0 +1,154 @@ +from decimal import Decimal, ROUND_HALF_UP +from .utils import get_spot_price +from coinbase_advanced_trader.legacy.cb_auth import CBAuth +from coinbase_advanced_trader.legacy.legacy_config import BUY_PRICE_MULTIPLIER, SELL_PRICE_MULTIPLIER +from coinbase_advanced_trader.legacy.coinbase_client import createOrder, generate_client_order_id, Side, getProduct + +# Initialize the single instance of CBAuth +cb_auth = CBAuth() + + +def fiat_limit_buy(product_id, fiat_amount, price_multiplier=BUY_PRICE_MULTIPLIER): + """ + Places a limit buy order. + + Args: + product_id (str): The ID of the product to buy (e.g., "BTC-USD"). + fiat_amount (float): The amount in USD or other fiat to spend on buying (ie. $200). + price_multiplier (float, optional): Multiplier to apply to the current spot price to get the limit price. Defaults to BUY_PRICE_MULTIPLIER. + + Returns: + dict: The response of the order details. + """ + # Coinbase maker fee rate + maker_fee_rate = Decimal('0.004') + + # Fetch product details to get the quote_increment and base_increment + product_details = getProduct(product_id) + quote_increment = Decimal(product_details['quote_increment']) + base_increment = Decimal(product_details['base_increment']) + + # Fetch the current spot price for the product + spot_price = get_spot_price(product_id) + + # Calculate the limit price + limit_price = Decimal(spot_price) * Decimal(price_multiplier) + + # Round the limit price to the appropriate number of decimal places + limit_price = limit_price.quantize(quote_increment) + + # Adjust the fiat_amount for the maker fee + effective_fiat_amount = Decimal(fiat_amount) * (1 - maker_fee_rate) + + # Calculate the equivalent amount in the base currency (e.g., BTC) for the given USD amount + base_size = effective_fiat_amount / limit_price + + # Round base_size to the nearest allowed increment + base_size = (base_size / base_increment).quantize(Decimal('1'), + rounding=ROUND_HALF_UP) * base_increment + + # Create order configuration + order_configuration = { + 'limit_price': str(limit_price), + 'base_size': str(base_size), + 'post_only': True + } + + # Send the order + order_details = createOrder( + client_order_id=generate_client_order_id(), + product_id=product_id, + side=Side.BUY.name, + order_type='limit_limit_gtc', + order_configuration=order_configuration + ) + + # Print a human-readable message + if order_details['success']: + base_size = Decimal( + order_details['order_configuration']['limit_limit_gtc']['base_size']) + limit_price = Decimal( + order_details['order_configuration']['limit_limit_gtc']['limit_price']) + total_amount = base_size * limit_price + print( + f"Successfully placed a limit buy order for {base_size} {product_id} (${total_amount:.2f}) at a price of {limit_price} USD.") + else: + print( + f"Failed to place a limit buy order. Reason: {order_details['failure_reason']}") + + print("Coinbase response:", order_details) + + return order_details + + +def fiat_limit_sell(product_id, fiat_amount, price_multiplier=SELL_PRICE_MULTIPLIER): + """ + Places a limit sell order. + + Args: + product_id (str): The ID of the product to sell (e.g., "BTC-USD"). + fiat_amount (float): The amount in USD or other fiat to receive from selling (ie. $200). + price_multiplier (float, optional): Multiplier to apply to the current spot price to get the limit price. Defaults to SELL_PRICE_MULTIPLIER. + + Returns: + dict: The response of the order details. + """ + # Coinbase maker fee rate + maker_fee_rate = Decimal('0.004') + + # Fetch product details to get the quote_increment and base_increment + product_details = getProduct(product_id) + quote_increment = Decimal(product_details['quote_increment']) + base_increment = Decimal(product_details['base_increment']) + + # Fetch the current spot price for the product + spot_price = get_spot_price(product_id) + + # Calculate the limit price + limit_price = Decimal(spot_price) * Decimal(price_multiplier) + + # Round the limit price to the appropriate number of decimal places + limit_price = limit_price.quantize(quote_increment) + + # Adjust the fiat_amount for the maker fee + effective_fiat_amount = Decimal(fiat_amount) / (1 - maker_fee_rate) + + # Calculate the equivalent amount in the base currency (e.g., BTC) for the given USD amount + base_size = effective_fiat_amount / limit_price + + # Round base_size to the nearest allowed increment + base_size = (base_size / base_increment).quantize(Decimal('1'), + rounding=ROUND_HALF_UP) * base_increment + + # Create order configuration + order_configuration = { + 'limit_price': str(limit_price), + 'base_size': str(base_size), + 'post_only': True + } + + # Send the order + order_details = createOrder( + client_order_id=generate_client_order_id(), + product_id=product_id, + side=Side.SELL.name, + order_type='limit_limit_gtc', + order_configuration=order_configuration + ) + + # Print a human-readable message + if order_details['success']: + base_size = Decimal( + order_details['order_configuration']['limit_limit_gtc']['base_size']) + limit_price = Decimal( + order_details['order_configuration']['limit_limit_gtc']['limit_price']) + total_amount = base_size * limit_price + print( + f"Successfully placed a limit sell order for {base_size} {product_id} (${total_amount:.2f}) at a price of {limit_price} USD.") + else: + print( + f"Failed to place a limit sell order. Reason: {order_details['failure_reason']}") + + print("Coinbase response:", order_details) + + return order_details diff --git a/coinbase_advanced_trader/legacy/strategies/market_order_strategies.py b/coinbase_advanced_trader/legacy/strategies/market_order_strategies.py new file mode 100644 index 0000000..e9d0c50 --- /dev/null +++ b/coinbase_advanced_trader/legacy/strategies/market_order_strategies.py @@ -0,0 +1,55 @@ +from decimal import Decimal, ROUND_HALF_UP +from coinbase_advanced_trader.legacy.cb_auth import CBAuth +from .utils import get_spot_price +from coinbase_advanced_trader.legacy.coinbase_client import createOrder, generate_client_order_id, Side, getProduct + +cb_auth = CBAuth() + + +def calculate_base_size(fiat_amount, spot_price, base_increment): + base_size = (fiat_amount / spot_price / base_increment).quantize( + Decimal('1'), rounding=ROUND_HALF_UP) * base_increment + return base_size + + +def _market_order(product_id, fiat_amount, side): + product_details = getProduct(product_id) + base_increment = Decimal(product_details['base_increment']) + spot_price = get_spot_price(product_id) + + if side == Side.SELL.name: + base_size = calculate_base_size( + fiat_amount, spot_price, base_increment) + order_configuration = {'base_size': str(base_size)} + else: + order_configuration = {'quote_size': str(fiat_amount)} + + order_details = createOrder( + client_order_id=generate_client_order_id(), + product_id=product_id, + side=side, + order_type='market_market_ioc', + order_configuration=order_configuration + ) + + if order_details['success']: + print( + f"Successfully placed a {side} order for {fiat_amount} USD of {product_id.split('-')[0]}.") + else: + failure_reason = order_details.get('failure_reason', '') + preview_failure_reason = order_details.get( + 'error_response', {}).get('preview_failure_reason', '') + print( + f"Failed to place a {side} order. Reason: {failure_reason}. Preview failure reason: {preview_failure_reason}") + + print("Coinbase response:", order_details) + + return order_details + + +def fiat_market_buy(product_id, fiat_amount): + return _market_order(product_id, fiat_amount, Side.BUY.name) + + +def fiat_market_sell(product_id, fiat_amount): + return _market_order(product_id, fiat_amount, Side.SELL.name) diff --git a/coinbase_advanced_trader/legacy/strategies/utils.py b/coinbase_advanced_trader/legacy/strategies/utils.py new file mode 100644 index 0000000..3c11d9a --- /dev/null +++ b/coinbase_advanced_trader/legacy/strategies/utils.py @@ -0,0 +1,37 @@ +from coinbase_advanced_trader.legacy.cb_auth import CBAuth +import coinbase_advanced_trader.legacy.coinbase_client as client +from decimal import Decimal + +# Get the singleton instance of CBAuth +cb_auth = CBAuth() + + +def get_spot_price(product_id): + """ + Fetches the current spot price of a specified product. + + Args: + product_id (str): The ID of the product (e.g., "BTC-USD"). + + Returns: + float: The spot price as a float, or None if an error occurs. + """ + try: + response = client.getProduct(product_id) + # print("Response:", response) # Log the entire response for debugging + quote_increment = Decimal(response['quote_increment']) + + # Check whether the 'price' field exists in the response and return it as a float + if 'price' in response: + price = Decimal(response['price']) + # Round the price to quote_increment number of digits + rounded_price = price.quantize(quote_increment) + return rounded_price + else: + # Print a specific error message if the 'price' field is missing + print(f"'price' field missing in response for {product_id}") + return None + + except Exception as e: + print(f"Error fetching spot price for {product_id}: {e}") + return None diff --git a/coinbase_advanced_trader/legacy/tests/__init__.py b/coinbase_advanced_trader/legacy/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/coinbase_advanced_trader/legacy/tests/test_fear_and_greed_strategies.py b/coinbase_advanced_trader/legacy/tests/test_fear_and_greed_strategies.py new file mode 100644 index 0000000..5223d09 --- /dev/null +++ b/coinbase_advanced_trader/legacy/tests/test_fear_and_greed_strategies.py @@ -0,0 +1,33 @@ +import unittest +from unittest.mock import patch +from coinbase_advanced_trader.legacy.strategies.fear_and_greed_strategies import get_fear_and_greed_index, trade_based_on_fgi_simple, trade_based_on_fgi_pro + + +class TestFearAndGreedStrategies(unittest.TestCase): + + @patch('coinbase_advanced_trader.legacy.strategies.fear_and_greed_strategies.requests.get') + def test_get_fear_and_greed_index(self, mock_get): + mock_get.return_value.json.return_value = { + 'data': [{'value': 50, 'value_classification': 'Neutral'}]} + self.assertEqual(get_fear_and_greed_index(), (50, 'Neutral')) + + @patch('coinbase_advanced_trader.legacy.strategies.fear_and_greed_strategies.get_fear_and_greed_index') + @patch('coinbase_advanced_trader.legacy.strategies.fear_and_greed_strategies.fiat_limit_buy') + def test_trade_based_on_fgi_simple(self, mock_buy, mock_index): + mock_index.return_value = (50, 'Neutral') + mock_buy.return_value = {'status': 'success'} + self.assertEqual(trade_based_on_fgi_simple( + 'BTC-USD', 100), {'status': 'success'}) + + @patch('coinbase_advanced_trader.legacy.strategies.fear_and_greed_strategies.get_fear_and_greed_index') + @patch('coinbase_advanced_trader.legacy.strategies.fear_and_greed_strategies.fiat_limit_buy') + @patch('coinbase_advanced_trader.legacy.strategies.fear_and_greed_strategies.fiat_limit_sell') + def test_trade_based_on_fgi_pro(self, mock_sell, mock_buy, mock_index): + mock_index.return_value = (80, 'Greed') + mock_sell.return_value = {'status': 'success'} + self.assertEqual(trade_based_on_fgi_pro( + 'BTC-USD', 100), {'status': 'success'}) + + +if __name__ == '__main__': + unittest.main() diff --git a/coinbase_advanced_trader/legacy/tests/test_limit_order_strategies.py b/coinbase_advanced_trader/legacy/tests/test_limit_order_strategies.py new file mode 100644 index 0000000..0051137 --- /dev/null +++ b/coinbase_advanced_trader/legacy/tests/test_limit_order_strategies.py @@ -0,0 +1,43 @@ +import unittest +from unittest.mock import patch +from decimal import Decimal +from coinbase_advanced_trader.legacy.strategies.limit_order_strategies import fiat_limit_buy, fiat_limit_sell + + +class TestLimitOrderStrategies(unittest.TestCase): + + @patch('coinbase_advanced_trader.legacy.strategies.limit_order_strategies.getProduct') + @patch('coinbase_advanced_trader.legacy.strategies.limit_order_strategies.get_spot_price') + @patch('coinbase_advanced_trader.legacy.strategies.limit_order_strategies.createOrder') + @patch('coinbase_advanced_trader.legacy.strategies.limit_order_strategies.generate_client_order_id') + def test_fiat_limit_buy(self, mock_generate_client_order_id, mock_createOrder, mock_get_spot_price, mock_getProduct): + # Mock the dependencies + mock_getProduct.return_value = { + 'quote_increment': '0.01', 'base_increment': '0.00000001'} + mock_get_spot_price.return_value = '28892.56' + mock_generate_client_order_id.return_value = 'example_order_id' + mock_createOrder.return_value = {'result': 'success'} + + # Test the function + result = fiat_limit_buy("BTC-USD", 200) + self.assertEqual(result['result'], 'success') + + @patch('coinbase_advanced_trader.legacy.strategies.limit_order_strategies.getProduct') + @patch('coinbase_advanced_trader.legacy.strategies.limit_order_strategies.get_spot_price') + @patch('coinbase_advanced_trader.legacy.strategies.limit_order_strategies.createOrder') + @patch('coinbase_advanced_trader.legacy.strategies.limit_order_strategies.generate_client_order_id') + def test_fiat_limit_sell(self, mock_generate_client_order_id, mock_createOrder, mock_get_spot_price, mock_getProduct): + # Mock the dependencies + mock_getProduct.return_value = { + 'quote_increment': '0.01', 'base_increment': '0.00000001'} + mock_get_spot_price.return_value = '28892.56' + mock_generate_client_order_id.return_value = 'example_order_id' + mock_createOrder.return_value = {'result': 'success'} + + # Test the function + result = fiat_limit_sell("BTC-USD", 200) + self.assertEqual(result['result'], 'success') + + +if __name__ == '__main__': + unittest.main() diff --git a/coinbase_advanced_trader/legacy/tests/test_market_order_strategies.py b/coinbase_advanced_trader/legacy/tests/test_market_order_strategies.py new file mode 100644 index 0000000..12ab8f8 --- /dev/null +++ b/coinbase_advanced_trader/legacy/tests/test_market_order_strategies.py @@ -0,0 +1,43 @@ +import unittest +from unittest.mock import patch +from decimal import Decimal +from coinbase_advanced_trader.legacy.strategies.market_order_strategies import fiat_market_buy, fiat_market_sell + + +class TestMarketOrderStrategies(unittest.TestCase): + + @patch('coinbase_advanced_trader.legacy.strategies.market_order_strategies.getProduct') + @patch('coinbase_advanced_trader.legacy.strategies.market_order_strategies.get_spot_price') + @patch('coinbase_advanced_trader.legacy.strategies.market_order_strategies.createOrder') + @patch('coinbase_advanced_trader.legacy.strategies.market_order_strategies.generate_client_order_id') + def test_fiat_market_buy(self, mock_generate_client_order_id, mock_createOrder, mock_get_spot_price, mock_getProduct): + # Mock the dependencies + mock_getProduct.return_value = { + 'quote_increment': '0.01', 'base_increment': '0.00000001'} + mock_get_spot_price.return_value = '28892.56' + mock_generate_client_order_id.return_value = 'example_order_id' + mock_createOrder.return_value = {'result': 'success'} + + # Test the function + result = fiat_market_buy("BTC-USD", 200) + self.assertEqual(result['result'], 'success') + + @patch('coinbase_advanced_trader.legacy.strategies.market_order_strategies.getProduct') + @patch('coinbase_advanced_trader.legacy.strategies.market_order_strategies.get_spot_price') + @patch('coinbase_advanced_trader.legacy.strategies.market_order_strategies.createOrder') + @patch('coinbase_advanced_trader.legacy.strategies.market_order_strategies.generate_client_order_id') + def test_fiat_market_sell(self, mock_generate_client_order_id, mock_createOrder, mock_get_spot_price, mock_getProduct): + # Mock the dependencies + mock_getProduct.return_value = { + 'quote_increment': '0.01', 'base_increment': '0.00000001'} + mock_get_spot_price.return_value = '28892.56' + mock_generate_client_order_id.return_value = 'example_order_id' + mock_createOrder.return_value = {'result': 'success'} + + # Test the function + result = fiat_market_sell("BTC-USD", 200) + self.assertEqual(result['result'], 'success') + + +if __name__ == '__main__': + unittest.main() diff --git a/coinbase_advanced_trader/legacy/tests/test_utils.py b/coinbase_advanced_trader/legacy/tests/test_utils.py new file mode 100644 index 0000000..9a337bf --- /dev/null +++ b/coinbase_advanced_trader/legacy/tests/test_utils.py @@ -0,0 +1,42 @@ +import unittest +from unittest.mock import patch, MagicMock +from coinbase_advanced_trader.legacy.strategies.utils import get_spot_price + + +class TestGetSpotPrice(unittest.TestCase): + @patch('coinbase_advanced_trader.legacy.strategies.utils.client.getProduct') + def test_get_spot_price_success(self, mock_get_product): + # Mock the getProduct function to return a successful response + mock_get_product.return_value = {'price': '50000'} + + # Test the function with a product_id + result = get_spot_price('BTC-USD') + + # Assert that the function returns the correct spot price + self.assertEqual(result, 50000.0) + + @patch('coinbase_advanced_trader.legacy.strategies.utils.client.getProduct') + def test_get_spot_price_failure(self, mock_get_product): + # Mock the getProduct function to return a response without a 'price' field + mock_get_product.return_value = {} + + # Test the function with a product_id + result = get_spot_price('BTC-USD') + + # Assert that the function returns None when the 'price' field is missing + self.assertIsNone(result) + + @patch('coinbase_advanced_trader.legacy.strategies.utils.client.getProduct') + def test_get_spot_price_exception(self, mock_get_product): + # Mock the getProduct function to raise an exception + mock_get_product.side_effect = Exception('Test exception') + + # Test the function with a product_id + result = get_spot_price('BTC-USD') + + # Assert that the function returns None when an exception is raised + self.assertIsNone(result) + + +if __name__ == '__main__': + unittest.main() diff --git a/coinbase_advanced_trader/legacy_tests/__init__.py b/coinbase_advanced_trader/legacy_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/coinbase_advanced_trader/legacy_tests/test_coinbase_client.py b/coinbase_advanced_trader/legacy_tests/test_coinbase_client.py new file mode 100644 index 0000000..e83abf5 --- /dev/null +++ b/coinbase_advanced_trader/legacy_tests/test_coinbase_client.py @@ -0,0 +1,572 @@ +import unittest +from unittest.mock import patch +from datetime import datetime +from coinbase_advanced_trader.legacy.coinbase_client import ( + Method, + listAccounts, + getAccount, + createOrder, + cancelOrders, + listOrders, + listFills, + getOrder, + listProducts, + getProduct, + getProductCandles, + getMarketTrades, + getTransactionsSummary, +) + + +class TestCoinbaseClient(unittest.TestCase): + @patch('coinbase_advanced_trader.coinbase_client.cb_auth') + def test_list_accounts(self, mock_cb_auth): + # Mock the response from the API + mock_cb_auth.return_value = { + "accounts": [{ + "uuid": "8bfc20d7-f7c6-4422-bf07-8243ca4169fe", + "name": "BTC Wallet", + "currency": "BTC", + "available_balance": { + "value": "1.23", + "currency": "BTC" + }, + "default": False, + "active": True, + "created_at": "2021-05-31T09:59:59Z", + "updated_at": "2021-05-31T09:59:59Z", + "deleted_at": "2021-05-31T09:59:59Z", + "type": "ACCOUNT_TYPE_UNSPECIFIED", + "ready": True, + "hold": { + "value": "1.23", + "currency": "BTC" + } + }], + "has_next": True, + "cursor": "789100", + "size": 1 + } + + # Call the function with sample input + result = listAccounts(limit=5, cursor=None) + + # Assert the expected output + self.assertIsNotNone(result) + self.assertIsInstance(result, dict) + self.assertIn('accounts', result) + self.assertIsInstance(result['accounts'], list) + self.assertEqual(len(result['accounts']), 1) + account = result['accounts'][0] + self.assertEqual( + account['uuid'], "8bfc20d7-f7c6-4422-bf07-8243ca4169fe") + self.assertEqual(account['name'], "BTC Wallet") + self.assertEqual(account['currency'], "BTC") + self.assertEqual(account['available_balance']['value'], "1.23") + self.assertEqual(account['available_balance']['currency'], "BTC") + self.assertFalse(account['default']) + self.assertTrue(account['active']) + self.assertEqual(account['created_at'], "2021-05-31T09:59:59Z") + self.assertEqual(account['updated_at'], "2021-05-31T09:59:59Z") + self.assertEqual(account['deleted_at'], "2021-05-31T09:59:59Z") + self.assertEqual(account['type'], "ACCOUNT_TYPE_UNSPECIFIED") + self.assertTrue(account['ready']) + self.assertEqual(account['hold']['value'], "1.23") + self.assertEqual(account['hold']['currency'], "BTC") + self.assertTrue(result['has_next']) + self.assertEqual(result['cursor'], "789100") + self.assertEqual(result['size'], 1) + + @patch('coinbase_advanced_trader.coinbase_client.cb_auth') + def test_get_account(self, mock_cb_auth): + # Mock the response from the API + mock_cb_auth.return_value = { + "account": { + "uuid": "8bfc20d7-f7c6-4422-bf07-8243ca4169fe", + "name": "BTC Wallet", + "currency": "BTC", + "available_balance": { + "value": "1.23", + "currency": "BTC" + }, + "default": False, + "active": True, + "created_at": "2021-05-31T09:59:59Z", + "updated_at": "2021-05-31T09:59:59Z", + "deleted_at": "2021-05-31T09:59:59Z", + "type": "ACCOUNT_TYPE_UNSPECIFIED", + "ready": True, + "hold": { + "value": "1.23", + "currency": "BTC" + } + } + } + + # Call the function with sample input + account_uuid = "8bfc20d7-f7c6-4422-bf07-8243ca4169fe" + result = getAccount(account_uuid) + + # Assert the expected output + self.assertIsNotNone(result) + self.assertIsInstance(result, dict) + self.assertIn('account', result) + account = result['account'] + self.assertEqual( + account['uuid'], "8bfc20d7-f7c6-4422-bf07-8243ca4169fe") + self.assertEqual(account['name'], "BTC Wallet") + self.assertEqual(account['currency'], "BTC") + self.assertEqual(account['available_balance']['value'], "1.23") + self.assertEqual(account['available_balance']['currency'], "BTC") + self.assertFalse(account['default']) + self.assertTrue(account['active']) + self.assertEqual(account['created_at'], "2021-05-31T09:59:59Z") + self.assertEqual(account['updated_at'], "2021-05-31T09:59:59Z") + self.assertEqual(account['deleted_at'], "2021-05-31T09:59:59Z") + self.assertEqual(account['type'], "ACCOUNT_TYPE_UNSPECIFIED") + self.assertTrue(account['ready']) + self.assertEqual(account['hold']['value'], "1.23") + self.assertEqual(account['hold']['currency'], "BTC") + + @patch('coinbase_advanced_trader.coinbase_client.cb_auth') + def test_createOrder(self, mock_cb_auth): + # Mock the cb_auth function to return a sample response + mock_cb_auth.return_value = {'result': 'success'} + + # Test the createOrder function + client_order_id = 'example_order_id' + product_id = 'BTC-USD' + side = 'buy' + order_type = 'limit_limit_gtc' + order_configuration = { + 'limit_price': '30000.00', + 'base_size': '0.01', + 'post_only': True + } + + result = createOrder( + client_order_id=client_order_id, + product_id=product_id, + side=side, + order_type=order_type, + order_configuration=order_configuration + ) + + # Check that the cb_auth function was called with the correct arguments + expected_payload = { + 'client_order_id': client_order_id, + 'product_id': product_id, + 'side': side, + 'order_configuration': { + order_type: order_configuration + } + } + mock_cb_auth.assert_called_with(Method.POST.value, '/api/v3/brokerage/orders', expected_payload) + + # Check that the createOrder function returns the response from cb_auth + self.assertEqual(result, {'result': 'success'}) + + @patch('coinbase_advanced_trader.coinbase_client.cb_auth') + def test_cancel_orders(self, mock_cb_auth): + # Mock the response from the API + mock_cb_auth.return_value = { + "results": { + "success": True, + "failure_reason": "UNKNOWN_CANCEL_FAILURE_REASON", + "order_id": "0000-00000" + } + } + + # Call the function with sample input + order_ids = ["0000-00000"] + result = cancelOrders(order_ids) + + # Assert the expected output + self.assertIsNotNone(result) + self.assertIsInstance(result, dict) + self.assertIn('results', result) + results = result['results'] + self.assertTrue(results['success']) + self.assertEqual(results['failure_reason'], + "UNKNOWN_CANCEL_FAILURE_REASON") + self.assertEqual(results['order_id'], "0000-00000") + + @patch('coinbase_advanced_trader.coinbase_client.cb_auth') + def test_list_orders(self, mock_cb_auth): + # Mock the response from the API + mock_cb_auth.return_value = { + "orders": { + "order_id": "0000-000000-000000", + "product_id": "BTC-USD", + "user_id": "2222-000000-000000", + "order_configuration": { + # Sample order configuration data + }, + "side": "UNKNOWN_ORDER_SIDE", + "client_order_id": "11111-000000-000000", + "status": "OPEN", + "time_in_force": "UNKNOWN_TIME_IN_FORCE", + "created_time": "2021-05-31T09:59:59Z", + "completion_percentage": "50", + "filled_size": "0.001", + "average_filled_price": "50", + "fee": "string", + "number_of_fills": "2", + "filled_value": "10000", + "pending_cancel": True, + "size_in_quote": False, + "total_fees": "5.00", + "size_inclusive_of_fees": False, + "total_value_after_fees": "string", + "trigger_status": "UNKNOWN_TRIGGER_STATUS", + "order_type": "UNKNOWN_ORDER_TYPE", + "reject_reason": "REJECT_REASON_UNSPECIFIED", + "settled": "boolean", + "product_type": "SPOT", + "reject_message": "string", + "cancel_message": "string", + "order_placement_source": "RETAIL_ADVANCED" + }, + "sequence": "string", + "has_next": True, + "cursor": "789100" + } + + # Call the function with sample input + result = listOrders() + + # Assert the expected output + self.assertIsNotNone(result) + self.assertIsInstance(result, dict) + self.assertIn('orders', result) + self.assertIn('sequence', result) + self.assertIn('has_next', result) + self.assertIn('cursor', result) + self.assertTrue(result['has_next']) + self.assertEqual(result['cursor'], '789100') + + @patch('coinbase_advanced_trader.coinbase_client.cb_auth') + def test_list_fills(self, mock_cb_auth): + # Mock the response from the API + mock_cb_auth.return_value = { + "fills": { + "entry_id": "22222-2222222-22222222", + "trade_id": "1111-11111-111111", + "order_id": "0000-000000-000000", + "trade_time": "2021-05-31T09:59:59Z", + "trade_type": "FILL", + "price": "10000.00", + "size": "0.001", + "commission": "1.25", + "product_id": "BTC-USD", + "sequence_timestamp": "2021-05-31T09:58:59Z", + "liquidity_indicator": "UNKNOWN_LIQUIDITY_INDICATOR", + "size_in_quote": False, + "user_id": "3333-333333-3333333", + "side": "UNKNOWN_ORDER_SIDE" + }, + "cursor": "789100" + } + + # Call the function with sample input + result = listFills(order_id="0000-000000-000000", product_id="BTC-USD") + + # Assert the expected output + self.assertIsNotNone(result) + self.assertIsInstance(result, dict) + self.assertIn('fills', result) + + fill = result['fills'] + self.assertEqual(fill['entry_id'], "22222-2222222-22222222") + self.assertEqual(fill['trade_id'], "1111-11111-111111") + self.assertEqual(fill['order_id'], "0000-000000-000000") + self.assertEqual(fill['trade_time'], "2021-05-31T09:59:59Z") + self.assertEqual(fill['trade_type'], "FILL") + self.assertEqual(fill['price'], "10000.00") + self.assertEqual(fill['size'], "0.001") + self.assertEqual(fill['commission'], "1.25") + self.assertEqual(fill['product_id'], "BTC-USD") + self.assertEqual(fill['sequence_timestamp'], "2021-05-31T09:58:59Z") + self.assertEqual(fill['liquidity_indicator'], + "UNKNOWN_LIQUIDITY_INDICATOR") + self.assertFalse(fill['size_in_quote']) + self.assertEqual(fill['user_id'], "3333-333333-3333333") + self.assertEqual(fill['side'], "UNKNOWN_ORDER_SIDE") + + self.assertEqual(result['cursor'], "789100") + + @patch('coinbase_advanced_trader.coinbase_client.cb_auth') + def test_get_order(self, mock_cb_auth): + # Mock the response from the API + mock_cb_auth.return_value = { + "order": { + "order_id": "0000-000000-000000", + "product_id": "BTC-USD", + "user_id": "2222-000000-000000", + "order_configuration": { + # Sample order configuration data + }, + "side": "UNKNOWN_ORDER_SIDE", + "client_order_id": "11111-000000-000000", + "status": "OPEN", + "time_in_force": "UNKNOWN_TIME_IN_FORCE", + "created_time": "2021-05-31T09:59:59Z", + "completion_percentage": "50", + "filled_size": "0.001", + "average_filled_price": "50", + "fee": "string", + "number_of_fills": "2", + "filled_value": "10000", + "pending_cancel": True, + "size_in_quote": False, + "total_fees": "5.00", + "size_inclusive_of_fees": False, + "total_value_after_fees": "string", + "trigger_status": "UNKNOWN_TRIGGER_STATUS", + "order_type": "UNKNOWN_ORDER_TYPE", + "reject_reason": "REJECT_REASON_UNSPECIFIED", + "settled": "boolean", + "product_type": "SPOT", + "reject_message": "string", + "cancel_message": "string", + "order_placement_source": "RETAIL_ADVANCED" + } + } + + # Call the function with sample input + order_id = "0000-000000-000000" + result = getOrder(order_id) + + # Assert the expected output + self.assertIsNotNone(result) + self.assertIsInstance(result, dict) + self.assertIn('order', result) + self.assertEqual(result['order']['order_id'], order_id) + self.assertEqual(result['order']['product_id'], 'BTC-USD') + self.assertEqual(result['order']['status'], 'OPEN') + + @patch('coinbase_advanced_trader.coinbase_client.cb_auth') + def test_list_products(self, mock_cb_auth): + # Mock the response from the API + mock_cb_auth.return_value = { + "products": { + "product_id": "BTC-USD", + "price": "140.21", + "price_percentage_change_24h": "9.43%", + "volume_24h": "1908432", + "volume_percentage_change_24h": "9.43%", + "base_increment": "0.00000001", + "quote_increment": "0.00000001", + "quote_min_size": "0.00000001", + "quote_max_size": "1000", + "base_min_size": "0.00000001", + "base_max_size": "1000", + "base_name": "Bitcoin", + "quote_name": "US Dollar", + "watched": True, + "is_disabled": False, + "new": True, + "status": "string", + "cancel_only": True, + "limit_only": True, + "post_only": True, + "trading_disabled": False, + "auction_mode": True, + "product_type": "SPOT", + "quote_currency_id": "USD", + "base_currency_id": "BTC", + "mid_market_price": "140.22", + "base_display_symbol": "BTC", + "quote_display_symbol": "USD" + }, + "num_products": 100 + } + + # Call the function with sample input + limit = 10 + offset = 0 + product_type = 'SPOT' + result = listProducts(limit=limit, offset=offset, + product_type=product_type) + + # Assert the expected output + self.assertIsNotNone(result) + self.assertIsInstance(result, dict) + self.assertIn('products', result) + self.assertIn('num_products', result) + self.assertEqual(result['products']['product_id'], 'BTC-USD') + self.assertEqual(result['num_products'], 100) + + @patch('coinbase_advanced_trader.coinbase_client.cb_auth') + def test_get_product(self, mock_cb_auth): + # Mock the response from the API + mock_cb_auth.return_value = { + "product_id": "BTC-USD", + "price": "140.21", + "price_percentage_change_24h": "9.43%", + "volume_24h": "1908432", + "volume_percentage_change_24h": "9.43%", + "base_increment": "0.00000001", + "quote_increment": "0.00000001", + "quote_min_size": "0.00000001", + "quote_max_size": "1000", + "base_min_size": "0.00000001", + "base_max_size": "1000", + "base_name": "Bitcoin", + "quote_name": "US Dollar", + "watched": True, + "is_disabled": False, + "new": True, + "status": "string", + "cancel_only": True, + "limit_only": True, + "post_only": True, + "trading_disabled": False, + "auction_mode": True, + "product_type": "SPOT", + "quote_currency_id": "USD", + "base_currency_id": "BTC", + "mid_market_price": "140.22", + "base_display_symbol": "BTC", + "quote_display_symbol": "USD" + } + + # Call the function with sample input + product_id = 'BTC-USD' + result = getProduct(product_id=product_id) + + # Assert the expected output + self.assertIsNotNone(result) + self.assertIsInstance(result, dict) + self.assertEqual(result['product_id'], 'BTC-USD') + self.assertEqual(result['price'], '140.21') + self.assertEqual(result['base_name'], 'Bitcoin') + self.assertEqual(result['quote_name'], 'US Dollar') + + @patch('coinbase_advanced_trader.coinbase_client.cb_auth') + def test_get_product_candles(self, mock_cb_auth): + # Mock the response from the API + mock_cb_auth.return_value = { + "candles": { + "start": "1639508050", + "low": "140.21", + "high": "140.21", + "open": "140.21", + "close": "140.21", + "volume": "56437345" + } + } + + # Call the function with sample input + product_id = "BTC-USD" + start = "1639508050" + end = "1639511650" + granularity = "3600" + + result = getProductCandles(product_id, start, end, granularity) + + # Assert the expected output + self.assertIsNotNone(result) + self.assertIsInstance(result, dict) + self.assertIn("candles", result) + self.assertEqual(result["candles"]["start"], start) + self.assertEqual(result["candles"]["low"], "140.21") + self.assertEqual(result["candles"]["high"], "140.21") + self.assertEqual(result["candles"]["open"], "140.21") + self.assertEqual(result["candles"]["close"], "140.21") + self.assertEqual(result["candles"]["volume"], "56437345") + + @patch('coinbase_advanced_trader.coinbase_client.cb_auth') + def test_get_market_trades(self, mock_cb_auth): + # Mock the response from the API + mock_cb_auth.return_value = { + "trades": { + "trade_id": "34b080bf-fcfd-445a-832b-46b5ddc65601", + "product_id": "BTC-USD", + "price": "140.91", + "size": "4", + "time": "2021-05-31T09:59:59Z", + "side": "UNKNOWN_ORDER_SIDE", + "bid": "291.13", + "ask": "292.40" + }, + "best_bid": "291.13", + "best_ask": "292.40" + } + + # Call the function with sample input + product_id = "BTC-USD" + limit = 10 + result = getMarketTrades(product_id, limit) + + # Assert the expected output + self.assertIsNotNone(result) + self.assertIsInstance(result, dict) + self.assertIn("trades", result) + self.assertEqual(result["trades"]["trade_id"], + "34b080bf-fcfd-445a-832b-46b5ddc65601") + self.assertEqual(result["trades"]["product_id"], product_id) + self.assertEqual(result["trades"]["price"], "140.91") + self.assertEqual(result["trades"]["size"], "4") + self.assertEqual(result["trades"]["side"], "UNKNOWN_ORDER_SIDE") + self.assertEqual(result["trades"]["bid"], "291.13") + self.assertEqual(result["trades"]["ask"], "292.40") + self.assertEqual(result["best_bid"], "291.13") + self.assertEqual(result["best_ask"], "292.40") + + @patch('coinbase_advanced_trader.coinbase_client.cb_auth') + def test_get_transactions_summary(self, mock_cb_auth): + # Mock the response from the API + mock_cb_auth.return_value = { + "total_volume": 1000, + "total_fees": 25, + "fee_tier": { + "pricing_tier": "<$10k", + "usd_from": "0", + "usd_to": "10,000", + "taker_fee_rate": "0.0010", + "maker_fee_rate": "0.0020" + }, + "margin_rate": { + "value": "string" + }, + "goods_and_services_tax": { + "rate": "string", + "type": "INCLUSIVE" + }, + "advanced_trade_only_volume": 1000, + "advanced_trade_only_fees": 25, + "coinbase_pro_volume": 1000, + "coinbase_pro_fees": 25 + } + # Call the function with sample input + start_date = datetime.strptime( + "2021-01-01T00:00:00Z", '%Y-%m-%dT%H:%M:%SZ') + end_date = datetime.strptime( + "2021-01-31T23:59:59Z", '%Y-%m-%dT%H:%M:%SZ') + user_native_currency = "USD" + product_type = "SPOT" + result = getTransactionsSummary( + start_date, end_date, user_native_currency, product_type) + + # Assert the expected output + self.assertIsNotNone(result) + self.assertIsInstance(result, dict) + self.assertEqual(result["total_volume"], 1000) + self.assertEqual(result["total_fees"], 25) + self.assertEqual(result["fee_tier"]["pricing_tier"], "<$10k") + self.assertEqual(result["fee_tier"]["usd_from"], "0") + self.assertEqual(result["fee_tier"]["usd_to"], "10,000") + self.assertEqual(result["fee_tier"]["taker_fee_rate"], "0.0010") + self.assertEqual(result["fee_tier"]["maker_fee_rate"], "0.0020") + self.assertEqual(result["margin_rate"]["value"], "string") + self.assertEqual(result["goods_and_services_tax"]["rate"], "string") + self.assertEqual(result["goods_and_services_tax"]["type"], "INCLUSIVE") + self.assertEqual(result["advanced_trade_only_volume"], 1000) + self.assertEqual(result["advanced_trade_only_fees"], 25) + self.assertEqual(result["coinbase_pro_volume"], 1000) + self.assertEqual(result["coinbase_pro_fees"], 25) + + +if __name__ == '__main__': + unittest.main() From 15d9cb91c63ade4651eb7e95b2a34067172f7ec8 Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Sat, 6 Jul 2024 16:57:42 -0500 Subject: [PATCH 18/23] Update README.md --- README.md | 125 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 72 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 7a441d0..f9304fd 100644 --- a/README.md +++ b/README.md @@ -1,100 +1,119 @@ # Coinbase Advanced Trade API Python Client -This is the unofficial Python client for the Coinbase Advanced Trade API. It allows users to interact with the API to manage their cryptocurrency trading activities on the Coinbase platform. +This is an unofficial Python client for the Coinbase Advanced Trade API. It allows users to interact with the API to manage their cryptocurrency trading activities on the Coinbase platform. ## Features - Easy-to-use Python wrapper for the Coinbase Advanced Trade API +- Supports the new Coinbase Cloud authentication method +- Built on top of the official Coinbase Python SDK for improved stability - Supports all endpoints and methods provided by the official API -- Lightweight and efficient wrapper - Added support for trading strategies covered on the [YouTube channel](https://rhett.blog/youtube) ## Setup - 1. Clone this repository or download the source files by running - ```bash - pip install coinbase-advancedtrade-python +1. Install the package using pip: + ```bash + pip install coinbase-advancedtrade-python + ``` - 2. Set your API key and secret in config.py. To obtain your API key and secret, follow the steps below: - - Log in to your Coinbase account. - - Navigate to API settings. - - Create a new API key with the appropriate permissions. - - Copy the API key and secret to config.py. +2. Obtain your API key and secret from the Coinbase Developer Platform. The new API key format looks like this: + ``` + API Key: organizations/{org_id}/apiKeys/{key_id} + API Secret: -----BEGIN EC PRIVATE KEY-----\n...\n-----END EC PRIVATE KEY-----\n + ``` ## Authentication -Here's an example of how to authenticate: -````python -from coinbase_advanced_trader.config import set_api_credentials +Here's an example of how to authenticate using the new method: -# Set your API key and secret -API_KEY = "ABCD1234" -API_SECRET = "XYZ9876" +```python +from coinbase_advanced_trader.enhanced_rest_client import EnhancedRESTClient -# Set the API credentials once, and it updates the CBAuth singleton instance -set_api_credentials(API_KEY, API_SECRET) -```` +api_key = "organizations/{org_id}/apiKeys/{key_id}" +api_secret = "-----BEGIN EC PRIVATE KEY-----\n...\n-----END EC PRIVATE KEY-----\n" + +client = EnhancedRESTClient(api_key=api_key, api_secret=api_secret) +``` ## Usage of Strategies -Here's an example of how to use the strategies package to buy $20 worth of Bitcoin: +Here's an example of how to use the strategies package to buy $10 worth of Bitcoin: + +```python +from coinbase_advanced_trader.enhanced_rest_client import EnhancedRESTClient -````python -from coinbase_advanced_trader.strategies.limit_order_strategies import fiat_limit_buy +client = EnhancedRESTClient(api_key=api_key, api_secret=api_secret) -# Define the trading parameters -product_id = "BTC-USD" # Replace with your desired trading pair -usd_size = 20 # Replace with your desired USD amount to spend`` +# Perform a market buy +client.fiat_market_buy("BTC-USDC", "10") # Perform a limit buy -limit_buy_order = fiat_limit_buy(product_id, usd_size) -```` +client.fiat_limit_buy("BTC-USDC", "10") +``` ## Usage of Fear and Greed Index -````python -from coinbase_advanced_trader.strategies.fear_and_greed_strategies import trade_based_on_fgi_simple -# Define the product id -product_id = "BTC-USD" +```python +from coinbase_advanced_trader.enhanced_rest_client import EnhancedRESTClient + +client = EnhancedRESTClient(api_key=api_key, api_secret=api_secret) -# Implement the strategy -trade_based_on_fgi_simple(product_id, 10) +# Trade based on Fear and Greed Index +client.trade_based_on_fgi("BTC-USDC", "10") +``` -```` +## Advanced Usage -## Usage of Fear and Greed Index (Pro) -````python -from coinbase_advanced_trader.strategies.fear_and_greed_strategies import trade_based_on_fgi_pro +You can also update and retrieve the Fear and Greed Index schedule: -# Define the product id -product_id = "BTC-USD" +```python +# Get current FGI schedule +current_schedule = client.get_fgi_schedule() -# Define the custom schedule -custom_schedule = [ - {"threshold": 20, "factor": 1, "action": "buy"}, - {"threshold": 80, "factor": 0.5, "action": "buy"}, - {"threshold": 100, "factor": 1, "action": "sell"}, +# Update FGI schedule +new_schedule = [ + {'threshold': 15, 'factor': 1.2, 'action': 'buy'}, + {'threshold': 37, 'factor': 1.0, 'action': 'buy'}, + {'threshold': 35, 'factor': 0.8, 'action': 'sell'}, + {'threshold': 45, 'factor': 0.6, 'action': 'sell'} ] +client.update_fgi_schedule(new_schedule) +``` + +## Legacy Support + +The legacy authentication method is still supported but moved to a separate module. It will not receive the latest updates from the Coinbase SDK. To use the legacy method: + +```python +from coinbase_advanced_trader.legacy.legacy_config import set_api_credentials +from coinbase_advanced_trader.legacy.strategies.limit_order_strategies import fiat_limit_buy -# Implement the strategy -response = trade_based_on_fgi_pro(product_id, 10, custom_schedule) -```` +legacy_key = "your_legacy_key" +legacy_secret = "your_legacy_secret" + +set_api_credentials(legacy_key, legacy_secret) + +# Use legacy functions +limit_buy_order = fiat_limit_buy("BTC-USDC", 10) +``` ## Documentation For more information about the Coinbase Advanced Trader API, consult the [official API documentation](https://docs.cloud.coinbase.com/advanced-trade-api/docs/rest-api-overview/). ## License - This project is licensed under the MIT License. See the LICENSE file for more information. + +This project is licensed under the MIT License. See the LICENSE file for more information. ## Author - Rhett Reisman - Email: rhett@rhett.blog +Rhett Reisman - GitHub: https://github.com/rhettre/coinbase-advancedtrade-python +Email: rhett@rhett.blog -## Disclaimer +GitHub: https://github.com/rhettre/coinbase-advancedtrade-python -This project is not affiliated with, maintained, or endorsed by Coinbase. Use this software at your own risk. Trading cryptocurrencies carries a risk of financial loss. The developers of this software are not responsible for any financial losses or damages incurred while using this software. Nothing in this software should be seen as an inducement to trade with a particular strategy or as financial advice. +## Disclaimer +This project is not affiliated with, maintained, or endorsed by Coinbase. Use this software at your own risk. Trading cryptocurrencies carries a risk of financial loss. The developers of this software are not responsible for any financial losses or damages incurred while using this software. Nothing in this software should be seen as an inducement to trade with a particular strategy or as financial advice. \ No newline at end of file From 3bc9c6d804963dda2af9971c2f140c5ac5d37f57 Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Sat, 6 Jul 2024 17:31:13 -0500 Subject: [PATCH 19/23] Requirements and gitignore --- .gitignore | 3 ++- coinbase_advanced_trader/.gitignore | 1 - requirements.txt | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) delete mode 100644 coinbase_advanced_trader/.gitignore diff --git a/.gitignore b/.gitignore index 0a3049a..050bcc7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ dist/ venv/ *log.txt coinbase_advanced_trader/__pycache__/ +coinbase_advanced_trader/legacy/__pycache__/ .cache/ .coverage tests/__pycache__/ @@ -16,4 +17,4 @@ tests/__pycache__/ .vscode/ test.py promptlib/ -coinbase_advanced_trader.log +*.log \ No newline at end of file diff --git a/coinbase_advanced_trader/.gitignore b/coinbase_advanced_trader/.gitignore deleted file mode 100644 index 7e99e36..0000000 --- a/coinbase_advanced_trader/.gitignore +++ /dev/null @@ -1 +0,0 @@ -*.pyc \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index f229360..cfaeb9f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ requests +coinbase-advanced-py \ No newline at end of file From b1827ad56d76adecdee9165ab318ba50c0d794a2 Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Sat, 6 Jul 2024 17:34:10 -0500 Subject: [PATCH 20/23] Update requirements.txt --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index cfaeb9f..5885f9e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ +coinbase-advanced-py requests -coinbase-advanced-py \ No newline at end of file +urllib3 \ No newline at end of file From 17e0c7aeb4493ee15e6181c3f20b7f4b747ce39c Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Sat, 6 Jul 2024 17:38:41 -0500 Subject: [PATCH 21/23] Update .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 050bcc7..1f38921 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ tests/__pycache__/ .vscode/ test.py promptlib/ -*.log \ No newline at end of file +*.log +.github/workflows \ No newline at end of file From e78b7a7c59480ca43f10c55f53efd825139a8968 Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Sat, 6 Jul 2024 17:42:45 -0500 Subject: [PATCH 22/23] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f9304fd..445784b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Coinbase Advanced Trade API Python Client -This is an unofficial Python client for the Coinbase Advanced Trade API. It allows users to interact with the API to manage their cryptocurrency trading activities on the Coinbase platform. +This is the unofficial Python client for the Coinbase Advanced Trade API. It allows users to interact with the API to manage their cryptocurrency trading activities on the Coinbase platform. ## Features From 001abfd9a1346da9e2052483472784668cf0c658 Mon Sep 17 00:00:00 2001 From: Rhett Reisman Date: Sat, 6 Jul 2024 17:43:55 -0500 Subject: [PATCH 23/23] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 445784b..e1e67de 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ This is the unofficial Python client for the Coinbase Advanced Trade API. It all - Easy-to-use Python wrapper for the Coinbase Advanced Trade API - Supports the new Coinbase Cloud authentication method -- Built on top of the official Coinbase Python SDK for improved stability +- Built on top of the official [Coinbase Python SDK](https://github.com/coinbase/coinbase-advanced-py) for improved stability - Supports all endpoints and methods provided by the official API - Added support for trading strategies covered on the [YouTube channel](https://rhett.blog/youtube)