diff --git a/README.md b/README.md index bb2e901..068bd0b 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ api_secret = "-----BEGIN EC PRIVATE KEY-----\n...\n-----END EC PRIVATE KEY-----\ client = EnhancedRESTClient(api_key=api_key, api_secret=api_secret) ``` -## Usage of Strategies +## Using the Official SDK The `EnhancedRESTClient` inherits from the Coinbase SDK's `RESTClient`, which means you can use all the functions provided by the official SDK. Here's an example of how to use the `get_product` function: @@ -48,13 +48,9 @@ print(product_info) ## Using Wrapper Strategies -Here's an example of how to use the strategies package to buy $10 worth of Bitcoin. By making assumptions about limit price in [trading_config.py](coinbase_advanced_trader/trading_config.py) we are able to simplify the syntax for making limit orders: +Here's an example of how to use the strategies package to buy $10 worth of Bitcoin. By making assumptions about limit price in [trading_config.py](coinbase_advanced_trader/trading_config.py) we are able to simplify the syntax for making orders: ```python -from coinbase_advanced_trader.enhanced_rest_client import EnhancedRESTClient - -client = EnhancedRESTClient(api_key=api_key, api_secret=api_secret) - # Perform a market buy client.fiat_market_buy("BTC-USDC", "10") @@ -77,19 +73,37 @@ client.fiat_limit_sell("BTC-USDC", "5") client.fiat_limit_sell("BTC-USDC", "5", price_multiplier="1.1") ``` -## Usage of Fear and Greed Index +### Account Balance Operations + +The `EnhancedRESTClient` provides methods to retrieve account balances for cryptocurrencies. These methods are particularly useful for managing and monitoring your cryptocurrency holdings on Coinbase. + +#### Listing All Non-Zero Crypto Balances + +To get a dictionary of all cryptocurrencies with non-zero balances in your account: ```python -from coinbase_advanced_trader.enhanced_rest_client import EnhancedRESTClient +balances = client.list_held_crypto_balances() +print(balances) +``` -client = EnhancedRESTClient(api_key=api_key, api_secret=api_secret) +#### Getting a Specific Crypto Balance + +To get the available balance of a specific cryptocurrency in your account (returns 0 if the specified cryptocurrency is not found in the account): + +```python +balance = client.get_crypto_balance("BTC") +print(balance) +``` +Note: Both methods use a caching mechanism to reduce API calls. The account data is cached for one hour before a fresh fetch is made from Coinbase. + +### Usage of Fear and Greed Index + +```python # Trade based on Fear and Greed Index client.trade_based_on_fgi("BTC-USDC", "10") ``` -## Advanced Usage - You can also update and retrieve the Fear and Greed Index schedule: ```python @@ -141,4 +155,4 @@ GitHub: https://github.com/rhettre/coinbase-advancedtrade-python ## 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. +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/enhanced_rest_client.py b/coinbase_advanced_trader/enhanced_rest_client.py index 35962e8..d3ac1c2 100644 --- a/coinbase_advanced_trader/enhanced_rest_client.py +++ b/coinbase_advanced_trader/enhanced_rest_client.py @@ -1,6 +1,7 @@ """Enhanced REST client for Coinbase Advanced Trading API.""" from typing import Optional, List, Dict, Any +from decimal import Decimal from coinbase.rest import RESTClient from .services.order_service import OrderService from .services.fear_and_greed_strategy import FearAndGreedStrategy @@ -8,6 +9,7 @@ from .trading_config import FearAndGreedConfig from coinbase_advanced_trader.constants import DEFAULT_CONFIG from coinbase_advanced_trader.logger import logger +from coinbase_advanced_trader.services.account_service import AccountService class EnhancedRESTClient(RESTClient): @@ -23,6 +25,7 @@ def __init__(self, api_key: str, api_secret: str, **kwargs: Any) -> None: **kwargs: Additional keyword arguments for RESTClient. """ super().__init__(api_key=api_key, api_secret=api_secret, **kwargs) + self._account_service = AccountService(self) self._price_service = PriceService(self) self._order_service = OrderService(self, self._price_service) self._config = FearAndGreedConfig() @@ -30,6 +33,25 @@ def __init__(self, api_key: str, api_secret: str, **kwargs: Any) -> None: self._order_service, self._price_service, self._config ) + def get_crypto_balance(self, currency: str) -> Decimal: + """ + Get the available balance of a specific cryptocurrency. + Args: + currency: The currency code (e.g., 'BTC', 'ETH', 'USDC'). + Returns: + The available balance of the specified cryptocurrency. + """ + return self._account_service.get_crypto_balance(currency) + + def list_held_crypto_balances(self) -> Dict[str, Decimal]: + """ + Get a dictionary of all held cryptocurrencies and their balances. + Returns: + Dict[str, Decimal]: A dictionary where the key is the currency code + and the value is the balance as a Decimal. + """ + return self._account_service.list_held_crypto_balances() + def update_fgi_schedule(self, new_schedule: List[Dict[str, Any]]) -> bool: """ Update the Fear and Greed Index trading schedule. diff --git a/coinbase_advanced_trader/services/account_service.py b/coinbase_advanced_trader/services/account_service.py new file mode 100644 index 0000000..1cb2dd8 --- /dev/null +++ b/coinbase_advanced_trader/services/account_service.py @@ -0,0 +1,65 @@ +from decimal import Decimal +from typing import Dict, List, Any, Optional +from datetime import datetime, timedelta + +from coinbase.rest import RESTClient + +from coinbase_advanced_trader.logger import logger + +class AccountService: + """Service for handling account-related operations.""" + + def __init__(self, rest_client: RESTClient): + self.rest_client = rest_client + self._accounts_cache = None + self._cache_timestamp = None + self._cache_duration = timedelta(hours=1) + + def _get_accounts(self, limit: int = 250) -> Dict[str, Dict[str, Any]]: + if self._accounts_cache is None or \ + (datetime.now() - self._cache_timestamp) > self._cache_duration: + logger.info("Fetching fresh account data from Coinbase") + response = self.rest_client.get_accounts(limit=limit) + self._accounts_cache = { + account['currency']: { + 'uuid': account['uuid'], + 'available_balance': Decimal(account['available_balance']['value']) + } + for account in response['accounts'] + } + logger.debug(f"Processed accounts cache: {self._accounts_cache}") + self._cache_timestamp = datetime.now() + return self._accounts_cache + + def get_crypto_balance(self, currency: str) -> Decimal: + try: + accounts = self._get_accounts() + if currency not in accounts: + logger.warning(f"No account found for {currency}") + return Decimal('0') + balance = accounts[currency]['available_balance'] + logger.info(f"Retrieved balance for {currency}: {balance}") + return balance + except Exception as e: + logger.error(f"Error retrieving balance for {currency}: {str(e)}") + raise + + def list_held_crypto_balances(self) -> Dict[str, Decimal]: + """ + Get a dictionary of all held cryptocurrencies and their balances. + Returns: + Dict[str, Decimal]: A dictionary where the key is the currency code + and the value is the balance as a Decimal. + """ + accounts = self._get_accounts() + non_zero_balances = { + currency: account['available_balance'] + for currency, account in accounts.items() + if account['available_balance'] > 0 + } + + logger.info(f"Found {len(non_zero_balances)} non-zero balances:") + for currency, balance in non_zero_balances.items(): + logger.info(f"{currency}: {balance}") + + return non_zero_balances \ No newline at end of file diff --git a/coinbase_advanced_trader/tests/test_account_service.py b/coinbase_advanced_trader/tests/test_account_service.py new file mode 100644 index 0000000..a2b78f0 --- /dev/null +++ b/coinbase_advanced_trader/tests/test_account_service.py @@ -0,0 +1,116 @@ +"""Unit tests for the AccountService class.""" + +import unittest +from unittest.mock import Mock, patch +from decimal import Decimal +from datetime import datetime + +from coinbase.rest import RESTClient +from coinbase_advanced_trader.services.account_service import AccountService + + +class TestAccountService(unittest.TestCase): + """Test cases for the AccountService class.""" + + def setUp(self): + """Set up the test environment.""" + self.rest_client_mock = Mock(spec=RESTClient) + self.account_service = AccountService(self.rest_client_mock) + + def test_get_accounts_cache(self): + """Test the caching behavior of _get_accounts method.""" + mock_response = { + 'accounts': [ + { + 'uuid': 'abc123', + 'currency': 'BTC', + 'available_balance': {'value': '1.5', 'currency': 'BTC'} + }, + { + 'uuid': 'def456', + 'currency': 'ETH', + 'available_balance': {'value': '10.0', 'currency': 'ETH'} + } + ] + } + self.rest_client_mock.get_accounts.return_value = mock_response + + # First call should fetch from API + accounts = self.account_service._get_accounts() + self.rest_client_mock.get_accounts.assert_called_once() + self.assertEqual(len(accounts), 2) + self.assertEqual(accounts['BTC']['available_balance'], Decimal('1.5')) + + # Second call should use cache + self.rest_client_mock.get_accounts.reset_mock() + accounts = self.account_service._get_accounts() + self.rest_client_mock.get_accounts.assert_not_called() + + def test_get_crypto_balance(self): + """Test the get_crypto_balance method.""" + mock_accounts = { + 'BTC': {'uuid': 'abc123', 'available_balance': Decimal('1.5')}, + 'ETH': {'uuid': 'def456', 'available_balance': Decimal('10.0')} + } + self.account_service._get_accounts = Mock(return_value=mock_accounts) + + btc_balance = self.account_service.get_crypto_balance('BTC') + self.assertEqual(btc_balance, Decimal('1.5')) + + eth_balance = self.account_service.get_crypto_balance('ETH') + self.assertEqual(eth_balance, Decimal('10.0')) + + xrp_balance = self.account_service.get_crypto_balance('XRP') + self.assertEqual(xrp_balance, Decimal('0')) + + def test_list_held_cryptocurrencies(self): + """Test the list_held_cryptocurrencies method.""" + mock_accounts = { + 'BTC': {'uuid': 'abc123', 'available_balance': Decimal('1.5')}, + 'ETH': {'uuid': 'def456', 'available_balance': Decimal('0')}, + 'XRP': {'uuid': 'ghi789', 'available_balance': Decimal('100.0')} + } + self.account_service._get_accounts = Mock(return_value=mock_accounts) + + held_currencies = self.account_service.list_held_cryptocurrencies() + self.assertEqual(set(held_currencies), {'BTC', 'XRP'}) + + @patch('coinbase_advanced_trader.services.account_service.datetime') + def test_cache_expiration(self, mock_datetime): + """Test the cache expiration behavior.""" + mock_response = { + 'accounts': [ + { + 'uuid': 'abc123', + 'currency': 'BTC', + 'available_balance': {'value': '1.5', 'currency': 'BTC'} + } + ] + } + self.rest_client_mock.get_accounts.return_value = mock_response + + # Set initial time + mock_datetime.now.return_value = datetime(2023, 1, 1, 12, 0, 0) + + # First call should fetch from API + self.account_service._get_accounts() + self.rest_client_mock.get_accounts.assert_called_once() + + # Set time to 30 minutes later (within cache duration) + mock_datetime.now.return_value = datetime(2023, 1, 1, 12, 30, 0) + + # Second call should use cache + self.rest_client_mock.get_accounts.reset_mock() + self.account_service._get_accounts() + self.rest_client_mock.get_accounts.assert_not_called() + + # Set time to 61 minutes later (outside cache duration) + mock_datetime.now.return_value = datetime(2023, 1, 1, 13, 1, 0) + + # Third call should fetch from API again + self.account_service._get_accounts() + self.rest_client_mock.get_accounts.assert_called_once() + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/setup.py b/setup.py index 20f2ba8..b2c70bd 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ setup( name='coinbase-advancedtrade-python', - version='0.2.1', + version='0.2.2', description='The unofficial Python client for the Coinbase Advanced Trade API', long_description=long_description, long_description_content_type="text/markdown",