diff --git a/investing_algorithm_framework/__init__.py b/investing_algorithm_framework/__init__.py index 9b91c43f..ff338380 100644 --- a/investing_algorithm_framework/__init__.py +++ b/investing_algorithm_framework/__init__.py @@ -24,7 +24,8 @@ SnapshotInterval, AWS_S3_STATE_BUCKET_NAME, BacktestEvaluationFocus, \ save_backtests_to_directory, BacktestMetrics, DATA_DIRECTORY from .infrastructure import AzureBlobStorageStateHandler, \ - CSVOHLCVDataProvider, CCXTOHLCVDataProvider, PandasOHLCVDataProvider, \ + CSVOHLCVDataProvider, CCXTOHLCVDataProvider, CCXTTickerDataProvider, \ + PandasOHLCVDataProvider, \ AWSS3StorageStateHandler from .create_app import create_app from .download_data import download, download_v2, DownloadResult, \ @@ -110,6 +111,7 @@ 'DataType', 'CSVOHLCVDataProvider', "CCXTOHLCVDataProvider", + "CCXTTickerDataProvider", "DataProvider", "get_annual_volatility", "get_sortino_ratio", diff --git a/investing_algorithm_framework/infrastructure/__init__.py b/investing_algorithm_framework/infrastructure/__init__.py index 14373d92..31ec9b81 100644 --- a/investing_algorithm_framework/infrastructure/__init__.py +++ b/investing_algorithm_framework/infrastructure/__init__.py @@ -12,7 +12,7 @@ BacktestService from .data_providers import CSVOHLCVDataProvider, get_default_data_providers, \ get_default_ohlcv_data_providers, CCXTOHLCVDataProvider, \ - PandasOHLCVDataProvider + CCXTTickerDataProvider, PandasOHLCVDataProvider from .order_executors import CCXTOrderExecutor, BacktestOrderExecutor from .portfolio_providers import CCXTPortfolioProvider @@ -46,6 +46,7 @@ "get_default_ohlcv_data_providers", "AWSS3StorageStateHandler", "CCXTOHLCVDataProvider", + "CCXTTickerDataProvider", "BacktestOrderExecutor", "PandasOHLCVDataProvider", "BacktestService", diff --git a/investing_algorithm_framework/infrastructure/data_providers/__init__.py b/investing_algorithm_framework/infrastructure/data_providers/__init__.py index 73de3446..5a7dc40b 100644 --- a/investing_algorithm_framework/infrastructure/data_providers/__init__.py +++ b/investing_algorithm_framework/infrastructure/data_providers/__init__.py @@ -1,4 +1,4 @@ -from .ccxt import CCXTOHLCVDataProvider +from .ccxt import CCXTOHLCVDataProvider, CCXTTickerDataProvider from .csv import CSVOHLCVDataProvider from .pandas import PandasOHLCVDataProvider @@ -12,6 +12,7 @@ def get_default_data_providers(): """ return [ CCXTOHLCVDataProvider(), + CCXTTickerDataProvider(), ] @@ -30,6 +31,7 @@ def get_default_ohlcv_data_providers(): __all__ = [ 'CSVOHLCVDataProvider', 'CCXTOHLCVDataProvider', + 'CCXTTickerDataProvider', 'get_default_data_providers', 'get_default_ohlcv_data_providers', 'PandasOHLCVDataProvider', diff --git a/investing_algorithm_framework/infrastructure/data_providers/ccxt.py b/investing_algorithm_framework/infrastructure/data_providers/ccxt.py index 3e3d5d08..d54d0c04 100644 --- a/investing_algorithm_framework/infrastructure/data_providers/ccxt.py +++ b/investing_algorithm_framework/infrastructure/data_providers/ccxt.py @@ -1256,3 +1256,162 @@ def get_data_source_file_path(self) -> Union[str, None]: locally, otherwise None. """ return self.data_file_path + + +class CCXTTickerDataProvider(DataProvider): + """ + Data provider for ticker data using the CCXT library. + + Fetches real-time ticker data (bid, ask, last price, volume, etc.) + for a given symbol and market via CCXT's fetch_ticker API. + + In backtest mode, ticker data is derived from OHLCV data + (handled by the DataProviderService fallback), so this provider + only serves live/non-backtest use cases. + """ + data_type = DataType.TICKER + data_provider_identifier = "ccxt_ticker_data_provider" + + def __init__( + self, + symbol: str = None, + market: str = None, + data_provider_identifier: str = None, + config=None + ): + if data_provider_identifier is None: + data_provider_identifier = self.data_provider_identifier + + super().__init__( + symbol=symbol, + market=market, + data_provider_identifier=data_provider_identifier, + config=config + ) + + def has_data( + self, + data_source: DataSource, + start_date: datetime = None, + end_date: datetime = None, + ) -> bool: + data_type = data_source.data_type + market = data_source.market + symbol = data_source.symbol + + if not DataType.TICKER.equals(data_type): + return False + + if market is None: + market = "binance" + + try: + market = market.lower() + exchange_class = getattr(ccxt, market) + exchange = exchange_class() + symbols = list(exchange.load_markets().keys()) + return symbol in symbols + except ccxt.NetworkError: + return False + except Exception as e: + logger.error(e) + return False + + def prepare_backtest_data( + self, + backtest_start_date, + backtest_end_date, + fill_missing_data: bool = False, + show_progress: bool = False, + ) -> None: + # Ticker backtest data is derived from OHLCV by the + # DataProviderService fallback — nothing to prepare here. + pass + + def get_backtest_data( + self, + backtest_index_date: datetime, + backtest_start_date: datetime = None, + backtest_end_date: datetime = None, + data_source: DataSource = None, + ): + # Backtest ticker data is handled by DataProviderService + # falling back to OHLCV data. + return None + + def get_data( + self, + date: datetime = None, + start_date: datetime = None, + end_date: datetime = None, + save: bool = False, + ) -> dict: + if self.market is None: + raise OperationalException( + "Market is not set. Please set the market " + "before calling get_data." + ) + + if self.symbol is None: + raise OperationalException( + "Symbol is not set. Please set the symbol " + "before calling get_data." + ) + + market_credential = self.get_credential(self.market) + exchange = CCXTOHLCVDataProvider.initialize_exchange( + self.market, market_credential + ) + ticker = exchange.fetch_ticker(self.symbol) + + return { + "symbol": self.symbol, + "market": self.market, + "datetime": ticker.get("datetime"), + "high": ticker.get("high"), + "low": ticker.get("low"), + "bid": ticker.get("bid"), + "ask": ticker.get("ask"), + "open": ticker.get("open"), + "close": ticker.get("close"), + "last": ticker.get("last"), + "volume": ticker.get("baseVolume"), + } + + def copy(self, data_source: DataSource) -> "CCXTTickerDataProvider": + if data_source.market is None or data_source.market == "": + raise OperationalException( + "DataSource has no `market` attribute specified. " + "Please specify the market attribute in the data source " + "specification before using the CCXT ticker data provider." + ) + + if data_source.symbol is None or data_source.symbol == "": + raise OperationalException( + "DataSource has no `symbol` attribute specified. " + "Please specify the symbol attribute in the data source " + "specification before using the CCXT ticker data provider." + ) + + return CCXTTickerDataProvider( + symbol=data_source.symbol, + market=data_source.market, + data_provider_identifier=data_source.data_provider_identifier, + config=self.config, + ) + + def get_number_of_data_points( + self, start_date: datetime, end_date: datetime + ) -> int: + # Ticker data is a single point-in-time snapshot + return 1 + + def get_missing_data_dates( + self, start_date: datetime, end_date: datetime + ) -> list: + # No stored data to have gaps in + return [] + + def get_data_source_file_path(self): + # Ticker data is not file-based + return None diff --git a/tests/infrastructure/data_providers/test_ccxt_ticker_data_provider.py b/tests/infrastructure/data_providers/test_ccxt_ticker_data_provider.py new file mode 100644 index 00000000..622557b0 --- /dev/null +++ b/tests/infrastructure/data_providers/test_ccxt_ticker_data_provider.py @@ -0,0 +1,192 @@ +from datetime import datetime, timezone +from unittest import TestCase +from unittest.mock import patch, MagicMock + +from investing_algorithm_framework.domain import DataSource, DataType, \ + OperationalException +from investing_algorithm_framework.infrastructure import \ + CCXTTickerDataProvider + + +class TestCCXTTickerDataProviderHasData(TestCase): + """Tests for CCXTTickerDataProvider.has_data()""" + + def test_returns_false_for_non_ticker_data_type(self): + provider = CCXTTickerDataProvider() + data_source = DataSource( + market="binance", + symbol="BTC/USDT", + data_type="ohlcv", + ) + self.assertFalse(provider.has_data(data_source)) + + @patch("investing_algorithm_framework.infrastructure" + ".data_providers.ccxt.ccxt") + def test_returns_true_when_symbol_exists(self, mock_ccxt_module): + mock_exchange = MagicMock() + mock_exchange.load_markets.return_value = { + "BTC/USDT": {}, "ETH/USDT": {} + } + mock_exchange_class = MagicMock(return_value=mock_exchange) + mock_ccxt_module.binance = mock_exchange_class + + provider = CCXTTickerDataProvider() + data_source = DataSource( + market="binance", + symbol="BTC/USDT", + data_type="ticker", + ) + self.assertTrue(provider.has_data(data_source)) + + @patch("investing_algorithm_framework.infrastructure" + ".data_providers.ccxt.ccxt") + def test_returns_false_when_symbol_not_found(self, mock_ccxt_module): + mock_exchange = MagicMock() + mock_exchange.load_markets.return_value = {"ETH/USDT": {}} + mock_exchange_class = MagicMock(return_value=mock_exchange) + mock_ccxt_module.binance = mock_exchange_class + + provider = CCXTTickerDataProvider() + data_source = DataSource( + market="binance", + symbol="BTC/USDT", + data_type="ticker", + ) + self.assertFalse(provider.has_data(data_source)) + + @patch("investing_algorithm_framework.infrastructure" + ".data_providers.ccxt.ccxt") + def test_defaults_market_to_binance(self, mock_ccxt_module): + mock_exchange = MagicMock() + mock_exchange.load_markets.return_value = {"BTC/USDT": {}} + mock_exchange_class = MagicMock(return_value=mock_exchange) + mock_ccxt_module.binance = mock_exchange_class + + provider = CCXTTickerDataProvider() + data_source = DataSource( + symbol="BTC/USDT", + data_type="ticker", + ) + self.assertTrue(provider.has_data(data_source)) + mock_ccxt_module.binance.assert_called_once() + + +class TestCCXTTickerDataProviderGetData(TestCase): + """Tests for CCXTTickerDataProvider.get_data()""" + + @patch("investing_algorithm_framework.infrastructure" + ".data_providers.ccxt.CCXTOHLCVDataProvider.initialize_exchange") + def test_returns_ticker_dict(self, mock_init_exchange): + mock_exchange = MagicMock() + mock_exchange.fetch_ticker.return_value = { + "datetime": "2024-01-15T12:00:00Z", + "high": 43500.0, + "low": 42500.0, + "bid": 43000.0, + "ask": 43010.0, + "open": 42800.0, + "close": 43100.0, + "last": 43050.0, + "baseVolume": 1234.56, + } + mock_init_exchange.return_value = mock_exchange + + provider = CCXTTickerDataProvider( + symbol="BTC/USDT", market="binance" + ) + provider.config = {} + result = provider.get_data() + + self.assertEqual(result["symbol"], "BTC/USDT") + self.assertEqual(result["market"], "BINANCE") + self.assertEqual(result["last"], 43050.0) + self.assertEqual(result["bid"], 43000.0) + self.assertEqual(result["ask"], 43010.0) + self.assertEqual(result["volume"], 1234.56) + mock_exchange.fetch_ticker.assert_called_once_with("BTC/USDT") + + def test_raises_when_market_not_set(self): + provider = CCXTTickerDataProvider(symbol="BTC/USDT") + with self.assertRaises(OperationalException): + provider.get_data() + + def test_raises_when_symbol_not_set(self): + provider = CCXTTickerDataProvider(market="binance") + with self.assertRaises(OperationalException): + provider.get_data() + + +class TestCCXTTickerDataProviderCopy(TestCase): + """Tests for CCXTTickerDataProvider.copy()""" + + def test_copy_returns_new_instance(self): + provider = CCXTTickerDataProvider() + data_source = DataSource( + market="binance", + symbol="BTC/USDT", + data_type="ticker", + ) + copied = provider.copy(data_source) + self.assertIsInstance(copied, CCXTTickerDataProvider) + self.assertEqual(copied.symbol, "BTC/USDT") + self.assertEqual(copied.market, "BINANCE") + + def test_copy_raises_when_market_missing(self): + provider = CCXTTickerDataProvider() + data_source = DataSource( + symbol="BTC/USDT", + data_type="ticker", + ) + with self.assertRaises(OperationalException): + provider.copy(data_source) + + def test_copy_raises_when_symbol_missing(self): + provider = CCXTTickerDataProvider() + data_source = DataSource( + market="binance", + data_type="ticker", + ) + with self.assertRaises(OperationalException): + provider.copy(data_source) + + +class TestCCXTTickerDataProviderBacktest(TestCase): + """Tests for backtest-related methods""" + + def test_prepare_backtest_data_is_noop(self): + provider = CCXTTickerDataProvider() + # Should not raise + provider.prepare_backtest_data( + backtest_start_date=datetime(2024, 1, 1, tzinfo=timezone.utc), + backtest_end_date=datetime(2024, 1, 31, tzinfo=timezone.utc), + ) + + def test_get_backtest_data_returns_none(self): + provider = CCXTTickerDataProvider() + result = provider.get_backtest_data( + backtest_index_date=datetime(2024, 1, 15, tzinfo=timezone.utc), + ) + self.assertIsNone(result) + + +class TestCCXTTickerDataProviderAttributes(TestCase): + """Tests for class attributes and initialization""" + + def test_data_type_is_ticker(self): + self.assertEqual(CCXTTickerDataProvider.data_type, DataType.TICKER) + + def test_default_identifier(self): + provider = CCXTTickerDataProvider() + self.assertEqual( + provider.data_provider_identifier, + "ccxt_ticker_data_provider" + ) + + def test_custom_identifier(self): + provider = CCXTTickerDataProvider( + data_provider_identifier="my_custom_id" + ) + self.assertEqual( + provider.data_provider_identifier, + "my_custom_id" + ) diff --git a/tests/test_download.py b/tests/test_download.py index 56125904..768d4869 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -2,24 +2,41 @@ import unittest from pathlib import Path from unittest import TestCase +from unittest.mock import patch, MagicMock from datetime import datetime, timezone -from investing_algorithm_framework import download +import polars as pl +from investing_algorithm_framework import download class TestDownload(TestCase): - @unittest.skipIf(os.environ.get("CI"), "Requires pre-downloaded data") - def test_download_data_with_already_existing_data(self): - storage_path = Path(__file__).parent / "resources" / "data" + @patch("investing_algorithm_framework.infrastructure" + ".data_providers.ccxt.ccxt") + def test_download_data_with_already_existing_data(self, mock_ccxt_module): + """ + Test that download() works when local CSV data already exists. + Uses test_data/ohlcv CSVs; CCXT is mocked so no network call + is made (the provider reads from the local file). + """ + # Mock the exchange so has_data() succeeds without network + mock_exchange = MagicMock() + mock_exchange.load_markets.return_value = {"BTC/EUR": {}} + mock_exchange.timeframes = {"2h": "2h"} + mock_exchange_class = MagicMock(return_value=mock_exchange) + mock_ccxt_module.bitvavo = mock_exchange_class + + storage_path = ( + Path(__file__).parent / "resources" / "test_data" / "ohlcv" + ) data = download( symbol="BTC/EUR", market="BITVAVO", data_type="OHLCV", time_frame="2h", - start_date=datetime(2023, 1, 1, tzinfo=timezone.utc), - end_date=datetime(2023, 12, 31, tzinfo=timezone.utc), + start_date=datetime(2023, 8, 11, 16, 0, tzinfo=timezone.utc), + end_date=datetime(2023, 12, 2, 0, 0, tzinfo=timezone.utc), storage_path=str(storage_path) ) self.assertIsNotNone(data)