-
Notifications
You must be signed in to change notification settings - Fork 33
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #42 from rhettre/feature/coinbase-accounts
Account services
- Loading branch information
Showing
5 changed files
with
230 additions
and
13 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters