diff --git a/.gitignore b/.gitignore index 5e524d9..1f38921 100644 --- a/.gitignore +++ b/.gitignore @@ -9,10 +9,13 @@ dist/ venv/ *log.txt coinbase_advanced_trader/__pycache__/ +coinbase_advanced_trader/legacy/__pycache__/ .cache/ .coverage tests/__pycache__/ .pytest_cache .vscode/ test.py -promptlib/ \ No newline at end of file +promptlib/ +*.log +.github/workflows \ No newline at end of file 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 diff --git a/README.md b/README.md index 7a441d0..e1e67de 100644 --- a/README.md +++ b/README.md @@ -5,96 +5,115 @@ This is the unofficial Python client for the Coinbase Advanced Trade API. It all ## 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](https://github.com/coinbase/coinbase-advanced-py) 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 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/coinbase_advanced_trader/config.py b/coinbase_advanced_trader/config.py index 52f085d..9eb8e7d 100644 --- a/coinbase_advanced_trader/config.py +++ b/coinbase_advanced_trader/config.py @@ -1,52 +1,51 @@ -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 - -# 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) +"""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: + 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: + 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 new file mode 100644 index 0000000..5e08042 --- /dev/null +++ b/coinbase_advanced_trader/constants.py @@ -0,0 +1,10 @@ +"""Constants and default configuration for the Coinbase Advanced Trader.""" + +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/enhanced_rest_client.py b/coinbase_advanced_trader/enhanced_rest_client.py new file mode 100644 index 0000000..e499125 --- /dev/null +++ b/coinbase_advanced_trader/enhanced_rest_client.py @@ -0,0 +1,184 @@ +"""Enhanced REST client for Coinbase Advanced Trading API.""" + +from typing import Optional, List, Dict, Any +from coinbase.rest import RESTClient +from .services.order_service import OrderService +from .services.fear_and_greed_strategy import FearAndGreedStrategy +from .services.price_service import PriceService +from .trading_config import FearAndGreedConfig +from coinbase_advanced_trader.constants import DEFAULT_CONFIG +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: Any) -> None: + """ + Initialize the EnhancedRESTClient. + + Args: + 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._config = FearAndGreedConfig() + self._fear_and_greed_strategy = FearAndGreedStrategy( + self._order_service, self._price_service, self._config + ) + + def update_fgi_schedule(self, new_schedule: List[Dict[str, Any]]) -> bool: + """ + Update the Fear and Greed Index trading schedule. + + Args: + 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 = [ + ... {'threshold': 20, 'factor': 1.2, 'action': 'buy'}, + ... {'threshold': 80, 'factor': 0.8, 'action': 'sell'} + ... ] + >>> 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: + 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 + + 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: 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: 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) -> Dict[str, Any]: + """ + Place a market sell order with fiat currency. + + Args: + product_id: The product identifier. + fiat_amount: 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 = DEFAULT_CONFIG['BUY_PRICE_MULTIPLIER'] + ) -> Dict[str, Any]: + """ + Place a limit buy order with fiat currency. + + Args: + 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. + """ + 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 = DEFAULT_CONFIG['SELL_PRICE_MULTIPLIER'] + ) -> Dict[str, Any]: + """ + Place a limit sell order with fiat currency. + + Args: + 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. + """ + 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[List[Dict[str, Any]]] = None + ) -> Dict[str, Any]: + """ + Execute a complex trade based on the Fear and Greed Index. + + Args: + 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._fear_and_greed_strategy.execute_trade( + product_id, fiat_amount + ) diff --git a/coinbase_advanced_trader/strategies/__init__.py b/coinbase_advanced_trader/legacy/__init__.py similarity index 100% rename from coinbase_advanced_trader/strategies/__init__.py rename to coinbase_advanced_trader/legacy/__init__.py diff --git a/coinbase_advanced_trader/cb_auth.py b/coinbase_advanced_trader/legacy/cb_auth.py similarity index 100% rename from coinbase_advanced_trader/cb_auth.py rename to coinbase_advanced_trader/legacy/cb_auth.py diff --git a/coinbase_advanced_trader/coinbase_client.py b/coinbase_advanced_trader/legacy/coinbase_client.py similarity index 99% rename from coinbase_advanced_trader/coinbase_client.py rename to coinbase_advanced_trader/legacy/coinbase_client.py index b68571e..6832dc9 100644 --- a/coinbase_advanced_trader/coinbase_client.py +++ b/coinbase_advanced_trader/legacy/coinbase_client.py @@ -1,8 +1,7 @@ from enum import Enum -from datetime import datetime import uuid import json -from cb_auth import CBAuth +from coinbase_advanced_trader.legacy.cb_auth import CBAuth # Initialize the single instance of CBAuth cb_auth = CBAuth() 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/tests/__init__.py b/coinbase_advanced_trader/legacy/strategies/__init__.py similarity index 100% rename from coinbase_advanced_trader/tests/__init__.py rename to coinbase_advanced_trader/legacy/strategies/__init__.py diff --git a/coinbase_advanced_trader/strategies/fear_and_greed_strategies.py b/coinbase_advanced_trader/legacy/strategies/fear_and_greed_strategies.py similarity index 94% rename from coinbase_advanced_trader/strategies/fear_and_greed_strategies.py rename to coinbase_advanced_trader/legacy/strategies/fear_and_greed_strategies.py index d6d79a7..565dfe6 100644 --- a/coinbase_advanced_trader/strategies/fear_and_greed_strategies.py +++ b/coinbase_advanced_trader/legacy/strategies/fear_and_greed_strategies.py @@ -1,6 +1,6 @@ import requests -from coinbase_advanced_trader.strategies.limit_order_strategies import fiat_limit_buy, fiat_limit_sell -from ..config import SIMPLE_SCHEDULE, PRO_SCHEDULE +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(): diff --git a/coinbase_advanced_trader/strategies/limit_order_strategies.py b/coinbase_advanced_trader/legacy/strategies/limit_order_strategies.py similarity index 95% rename from coinbase_advanced_trader/strategies/limit_order_strategies.py rename to coinbase_advanced_trader/legacy/strategies/limit_order_strategies.py index 12382e4..1b4042e 100644 --- a/coinbase_advanced_trader/strategies/limit_order_strategies.py +++ b/coinbase_advanced_trader/legacy/strategies/limit_order_strategies.py @@ -1,8 +1,8 @@ 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 +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() diff --git a/coinbase_advanced_trader/strategies/market_order_strategies.py b/coinbase_advanced_trader/legacy/strategies/market_order_strategies.py similarity index 91% rename from coinbase_advanced_trader/strategies/market_order_strategies.py rename to coinbase_advanced_trader/legacy/strategies/market_order_strategies.py index bfb1d87..e9d0c50 100644 --- a/coinbase_advanced_trader/strategies/market_order_strategies.py +++ b/coinbase_advanced_trader/legacy/strategies/market_order_strategies.py @@ -1,7 +1,7 @@ from decimal import Decimal, ROUND_HALF_UP -from coinbase_advanced_trader.cb_auth import CBAuth +from coinbase_advanced_trader.legacy.cb_auth import CBAuth from .utils import get_spot_price -from coinbase_advanced_trader.coinbase_client import createOrder, generate_client_order_id, Side, getProduct +from coinbase_advanced_trader.legacy.coinbase_client import createOrder, generate_client_order_id, Side, getProduct cb_auth = CBAuth() diff --git a/coinbase_advanced_trader/strategies/utils.py b/coinbase_advanced_trader/legacy/strategies/utils.py similarity index 90% rename from coinbase_advanced_trader/strategies/utils.py rename to coinbase_advanced_trader/legacy/strategies/utils.py index 9445882..3c11d9a 100644 --- a/coinbase_advanced_trader/strategies/utils.py +++ b/coinbase_advanced_trader/legacy/strategies/utils.py @@ -1,5 +1,5 @@ -from coinbase_advanced_trader.cb_auth import CBAuth -import coinbase_advanced_trader.coinbase_client as client +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 diff --git a/tests/__init__.py b/coinbase_advanced_trader/legacy/tests/__init__.py similarity index 100% rename from tests/__init__.py rename to coinbase_advanced_trader/legacy/tests/__init__.py diff --git a/coinbase_advanced_trader/tests/test_fear_and_greed_strategies.py b/coinbase_advanced_trader/legacy/tests/test_fear_and_greed_strategies.py similarity index 55% rename from coinbase_advanced_trader/tests/test_fear_and_greed_strategies.py rename to coinbase_advanced_trader/legacy/tests/test_fear_and_greed_strategies.py index 85a39de..5223d09 100644 --- a/coinbase_advanced_trader/tests/test_fear_and_greed_strategies.py +++ b/coinbase_advanced_trader/legacy/tests/test_fear_and_greed_strategies.py @@ -1,27 +1,27 @@ 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 +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.strategies.fear_and_greed_strategies.requests.get') + @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.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.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.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') + @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'} diff --git a/coinbase_advanced_trader/tests/test_limit_order_strategies.py b/coinbase_advanced_trader/legacy/tests/test_limit_order_strategies.py similarity index 60% rename from coinbase_advanced_trader/tests/test_limit_order_strategies.py rename to coinbase_advanced_trader/legacy/tests/test_limit_order_strategies.py index 4e2f258..0051137 100644 --- a/coinbase_advanced_trader/tests/test_limit_order_strategies.py +++ b/coinbase_advanced_trader/legacy/tests/test_limit_order_strategies.py @@ -1,15 +1,15 @@ 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 +from coinbase_advanced_trader.legacy.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') + @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 = { @@ -22,10 +22,10 @@ def test_fiat_limit_buy(self, mock_generate_client_order_id, mock_createOrder, m 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') + @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 = { diff --git a/coinbase_advanced_trader/tests/test_market_order_strategies.py b/coinbase_advanced_trader/legacy/tests/test_market_order_strategies.py similarity index 60% rename from coinbase_advanced_trader/tests/test_market_order_strategies.py rename to coinbase_advanced_trader/legacy/tests/test_market_order_strategies.py index 171b103..12ab8f8 100644 --- a/coinbase_advanced_trader/tests/test_market_order_strategies.py +++ b/coinbase_advanced_trader/legacy/tests/test_market_order_strategies.py @@ -1,15 +1,15 @@ 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 +from coinbase_advanced_trader.legacy.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') + @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 = { @@ -22,10 +22,10 @@ def test_fiat_market_buy(self, mock_generate_client_order_id, mock_createOrder, 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') + @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 = { diff --git a/coinbase_advanced_trader/tests/test_utils.py b/coinbase_advanced_trader/legacy/tests/test_utils.py similarity index 80% rename from coinbase_advanced_trader/tests/test_utils.py rename to coinbase_advanced_trader/legacy/tests/test_utils.py index 2df9b69..9a337bf 100644 --- a/coinbase_advanced_trader/tests/test_utils.py +++ b/coinbase_advanced_trader/legacy/tests/test_utils.py @@ -1,10 +1,10 @@ import unittest from unittest.mock import patch, MagicMock -from coinbase_advanced_trader.strategies.utils import get_spot_price +from coinbase_advanced_trader.legacy.strategies.utils import get_spot_price class TestGetSpotPrice(unittest.TestCase): - @patch('coinbase_advanced_trader.strategies.utils.client.getProduct') + @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'} @@ -15,7 +15,7 @@ def test_get_spot_price_success(self, mock_get_product): # Assert that the function returns the correct spot price self.assertEqual(result, 50000.0) - @patch('coinbase_advanced_trader.strategies.utils.client.getProduct') + @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 = {} @@ -26,7 +26,7 @@ def test_get_spot_price_failure(self, mock_get_product): # Assert that the function returns None when the 'price' field is missing self.assertIsNone(result) - @patch('coinbase_advanced_trader.strategies.utils.client.getProduct') + @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') 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/tests/test_coinbase_client.py b/coinbase_advanced_trader/legacy_tests/test_coinbase_client.py similarity index 99% rename from tests/test_coinbase_client.py rename to coinbase_advanced_trader/legacy_tests/test_coinbase_client.py index 6c62fb4..e83abf5 100644 --- a/tests/test_coinbase_client.py +++ b/coinbase_advanced_trader/legacy_tests/test_coinbase_client.py @@ -1,7 +1,7 @@ import unittest from unittest.mock import patch from datetime import datetime -from coinbase_advanced_trader.coinbase_client import ( +from coinbase_advanced_trader.legacy.coinbase_client import ( Method, listAccounts, getAccount, diff --git a/coinbase_advanced_trader/logger.py b/coinbase_advanced_trader/logger.py new file mode 100644 index 0000000..21c811b --- /dev/null +++ b/coinbase_advanced_trader/logger.py @@ -0,0 +1,42 @@ +import logging +import sys +from coinbase_advanced_trader.config import config_manager + + +def setup_logger(): + """ + 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)) + + # 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(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) + + # Add handlers to logger + logger.addHandler(console_handler) + logger.addHandler(file_handler) + + return logger + + +logger = setup_logger() \ No newline at end of file diff --git a/coinbase_advanced_trader/models/__init__.py b/coinbase_advanced_trader/models/__init__.py new file mode 100644 index 0000000..056245a --- /dev/null +++ b/coinbase_advanced_trader/models/__init__.py @@ -0,0 +1,6 @@ +"""Models package for Coinbase Advanced Trader.""" + +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..cc41e3c --- /dev/null +++ b/coinbase_advanced_trader/models/order.py @@ -0,0 +1,67 @@ +from dataclasses import dataclass +from decimal import Decimal +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 + type: OrderType + size: Decimal + price: Optional[Decimal] = None + client_order_id: Optional[str] = None + 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 new file mode 100644 index 0000000..347da3f --- /dev/null +++ b/coinbase_advanced_trader/models/product.py @@ -0,0 +1,49 @@ +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 + base_increment: Decimal + quote_increment: Decimal + min_market_funds: Decimal + max_market_funds: Decimal + status: str + trading_disabled: bool + + @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 new file mode 100644 index 0000000..63daef7 --- /dev/null +++ b/coinbase_advanced_trader/services/__init__.py @@ -0,0 +1,7 @@ +"""Services package for Coinbase Advanced Trader.""" + +from .order_service import OrderService +from .price_service import PriceService +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 new file mode 100644 index 0000000..eb839c2 --- /dev/null +++ b/coinbase_advanced_trader/services/fear_and_greed_strategy.py @@ -0,0 +1,106 @@ +import time +from decimal import Decimal +from typing import Optional, Tuple + +import requests + +from coinbase_advanced_trader.config import config_manager +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 + 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}) " + f"for trading {product_id}") + + fiat_amount = Decimal(fiat_amount) + schedule = self.config.get_fgi_schedule() + + for condition in schedule: + if self._should_execute_trade(condition, fgi): + adjusted_amount = fiat_amount * Decimal(condition['factor']) + 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 + + 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() + 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]: + """ + 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 + + @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 new file mode 100644 index 0000000..f80c810 --- /dev/null +++ b/coinbase_advanced_trader/services/order_service.py @@ -0,0 +1,237 @@ +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.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.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: + """ + 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: + """ + 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. + + 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) + + 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: + """ + Place a limit sell order for a specified fiat amount. + + 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']) + + 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 " + 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 " + 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. " + 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 new file mode 100644 index 0000000..52f1b59 --- /dev/null +++ b/coinbase_advanced_trader/services/price_service.py @@ -0,0 +1,60 @@ +from decimal import Decimal +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) -> 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']) + + if 'price' in response: + price = Decimal(response['price']) + return price.quantize(quote_increment) + + 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, 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']), + '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..f909ae3 --- /dev/null +++ b/coinbase_advanced_trader/services/trading_strategy_service.py @@ -0,0 +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): + """ + 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) -> 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..f4a9727 --- /dev/null +++ b/coinbase_advanced_trader/tests/test_enhanced_rest_client.py @@ -0,0 +1,173 @@ +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 FearAndGreedConfig + + +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=FearAndGreedConfig) + + 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() + + 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): + """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..72738a3 --- /dev/null +++ b/coinbase_advanced_trader/tests/test_fear_and_greed_strategy.py @@ -0,0 +1,124 @@ +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 FearAndGreedConfig + + +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=FearAndGreedConfig) + 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() + 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() + + @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() + 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() + + @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 new file mode 100644 index 0000000..4ee9573 --- /dev/null +++ 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 new file mode 100644 index 0000000..f8f872c --- /dev/null +++ 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..9c52a3d --- /dev/null +++ b/coinbase_advanced_trader/tests/test_trading_config.py @@ -0,0 +1,34 @@ +import unittest +from coinbase_advanced_trader.trading_config import FearAndGreedConfig + + +class TestTradingConfig(unittest.TestCase): + """Test cases for the TradingConfig class.""" + + def setUp(self): + """Set up the test environment before each test method.""" + self.config = FearAndGreedConfig() + + 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..aebabeb --- /dev/null +++ b/coinbase_advanced_trader/trading_config.py @@ -0,0 +1,92 @@ +"""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 + +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 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.0, 'action': 'sell'}, + {'threshold': 80, 'factor': 1.5, 'action': 'sell'}, + {'threshold': 90, 'factor': 2.0, 'action': 'sell'} + ] + + 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.") + else: + logger.error("Invalid FGI schedule. Update rejected.") + raise ValueError("Invalid FGI schedule") + + 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: List[Dict[str, Any]]) -> bool: + """ + Validate the given FGI schedule without updating it. + + Args: + schedule: The schedule to validate. + + Returns: + 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 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']}") + 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 diff --git a/coinbase_advanced_trader/utils/__init__.py b/coinbase_advanced_trader/utils/__init__.py new file mode 100644 index 0000000..b59c7b9 --- /dev/null +++ b/coinbase_advanced_trader/utils/__init__.py @@ -0,0 +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 new file mode 100644 index 0000000..8d424eb --- /dev/null +++ b/coinbase_advanced_trader/utils/helpers.py @@ -0,0 +1,33 @@ +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 diff --git a/requirements.txt b/requirements.txt index f229360..5885f9e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ +coinbase-advanced-py requests +urllib3 \ No newline at end of file