From e1f5b22a26b6f634496358619f6ac1b3a34f76f0 Mon Sep 17 00:00:00 2001 From: Jeff West Date: Sun, 10 Aug 2025 23:34:18 -0500 Subject: [PATCH] feat: add optional start_time and end_time parameters to get_bars method - Added start_time and end_time parameters to override days parameter - Support for both timezone-aware and naive datetime objects - Automatic conversion to UTC for API consistency - Smart defaults: end_time defaults to now, start_time uses days parameter - Full backward compatibility maintained - Added comprehensive test coverage for new parameters - Updated documentation for v3.1.5 release --- CHANGELOG.md | 18 ++ CLAUDE.md | 8 +- README.md | 10 +- docs/conf.py | 4 +- pyproject.toml | 2 +- src/project_x_py/__init__.py | 2 +- src/project_x_py/client/market_data.py | 55 +++++- src/project_x_py/indicators/__init__.py | 2 +- tests/client/test_market_data.py | 245 ++++++++++++++++++++++++ uv.lock | 2 +- 10 files changed, 330 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cfcc73..87ca3bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Migration guides will be provided for all breaking changes - Semantic versioning (MAJOR.MINOR.PATCH) is strictly followed +## [3.1.5] - 2025-08-11 + +### Added +- **📊 Enhanced Bar Data Retrieval**: Added optional `start_time` and `end_time` parameters to `get_bars()` method + - Allows precise time range specification for historical data queries + - Parameters override the `days` argument when provided + - Supports both timezone-aware and naive datetime objects + - Automatically converts times to UTC for API consistency + - Smart defaults: `end_time` defaults to now, `start_time` defaults based on `days` parameter + - Full backward compatibility maintained - existing code using `days` parameter continues to work + +### Tests +- Added comprehensive test coverage for new time-based parameters + - Tests for both `start_time` and `end_time` together + - Tests for individual parameter usage + - Tests for timezone-aware datetime handling + - Tests confirming time parameters override `days` parameter + ## [3.1.4] - 2025-08-10 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 6bcda21..455ac9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Project Status: v3.1.4 - Stable Production Release +## Project Status: v3.1.5 - Stable Production Release **IMPORTANT**: This project uses a fully asynchronous architecture. All APIs are async-only, optimized for high-performance futures trading. @@ -300,6 +300,12 @@ async with ProjectX.from_env() as client: ## Recent Changes +### v3.1.5 - Enhanced Bar Data Retrieval +- **Added**: Optional `start_time` and `end_time` parameters to `get_bars()` method +- **Improved**: Precise time range specification for historical data queries +- **Enhanced**: Full timezone support with automatic UTC conversion +- **Maintained**: Complete backward compatibility with existing `days` parameter + ### v3.1.4 - WebSocket Connection Fix - **Fixed**: Critical WebSocket error with missing `_use_batching` attribute - **Improved**: Proper mixin initialization in ProjectXRealtimeClient diff --git a/README.md b/README.md index 6eb6ad3..3664ce4 100644 --- a/README.md +++ b/README.md @@ -294,8 +294,14 @@ All 58+ indicators work with async data pipelines: import polars as pl from project_x_py.indicators import RSI, SMA, MACD, FVG, ORDERBLOCK, WAE -# Get data -data = await client.get_bars("ES", days=30) +# Get data - multiple ways +data = await client.get_bars("ES", days=30) # Last 30 days + +# Or use specific time range (v3.1.5+) +from datetime import datetime +start = datetime(2025, 1, 1, 9, 30) +end = datetime(2025, 1, 10, 16, 0) +data = await client.get_bars("ES", start_time=start, end_time=end) # Apply traditional indicators data = data.pipe(SMA, period=20).pipe(RSI, period=14) diff --git a/docs/conf.py b/docs/conf.py index d0f0de3..74035b2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,8 +23,8 @@ project = "project-x-py" copyright = "2025, Jeff West" author = "Jeff West" -release = "3.1.4" -version = "3.1.4" +release = "3.1.5" +version = "3.1.5" # -- General configuration --------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index d5c8520..4b2069c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "project-x-py" -version = "3.1.4" +version = "3.1.5" description = "High-performance Python SDK for futures trading with real-time WebSocket data, technical indicators, order management, and market depth analysis" readme = "README.md" license = { text = "MIT" } diff --git a/src/project_x_py/__init__.py b/src/project_x_py/__init__.py index bc78b2d..c7696cb 100644 --- a/src/project_x_py/__init__.py +++ b/src/project_x_py/__init__.py @@ -95,7 +95,7 @@ from project_x_py.client.base import ProjectXBase -__version__ = "3.1.4" +__version__ = "3.1.5" __author__ = "TexasCoding" # Core client classes - renamed from Async* to standard names diff --git a/src/project_x_py/client/market_data.py b/src/project_x_py/client/market_data.py index 2657b62..d5767ab 100644 --- a/src/project_x_py/client/market_data.py +++ b/src/project_x_py/client/market_data.py @@ -328,6 +328,8 @@ async def get_bars( unit: int = 2, limit: int | None = None, partial: bool = True, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None, ) -> pl.DataFrame: """ Retrieve historical OHLCV bar data for an instrument. @@ -338,12 +340,14 @@ async def get_bars( Args: symbol: Symbol of the instrument (e.g., "MGC", "MNQ", "ES") - days: Number of days of historical data (default: 8) + days: Number of days of historical data (default: 8, ignored if start_time/end_time provided) interval: Interval between bars in the specified unit (default: 5) unit: Time unit for the interval (default: 2 for minutes) 1=Second, 2=Minute, 3=Hour, 4=Day, 5=Week, 6=Month limit: Maximum number of bars to retrieve (auto-calculated if None) partial: Include incomplete/partial bars (default: True) + start_time: Optional start datetime (overrides days if provided) + end_time: Optional end datetime (defaults to now if not provided) Returns: pl.DataFrame: DataFrame with OHLCV data and timezone-aware timestamps @@ -371,6 +375,11 @@ async def get_bars( >>> # V3: Different time units available >>> # unit=1 (seconds), 2 (minutes), 3 (hours), 4 (days) >>> hourly_data = await client.get_bars("ES", days=1, interval=1, unit=3) + >>> # V3: Use specific time range + >>> from datetime import datetime + >>> start = datetime(2025, 1, 1, 9, 30) + >>> end = datetime(2025, 1, 1, 16, 0) + >>> data = await client.get_bars("ES", start_time=start, end_time=end) """ with LogContext( logger, @@ -383,8 +392,42 @@ async def get_bars( ): await self._ensure_authenticated() + # Calculate date range + from datetime import timedelta + + if start_time is not None or end_time is not None: + # Use provided time range + if start_time is not None: + # Ensure timezone awareness + if start_time.tzinfo is None: + start_date = pytz.UTC.localize(start_time) + else: + start_date = start_time.astimezone(pytz.UTC) + else: + # Default to days parameter ago if only end_time provided + start_date = datetime.datetime.now(pytz.UTC) - timedelta(days=days) + + if end_time is not None: + # Ensure timezone awareness + if end_time.tzinfo is None: + end_date = pytz.UTC.localize(end_time) + else: + end_date = end_time.astimezone(pytz.UTC) + else: + # Default to now if only start_time provided + end_date = datetime.datetime.now(pytz.UTC) + + # Calculate days for cache key (approximate) + days_calc = int((end_date - start_date).total_seconds() / 86400) + cache_key = f"{symbol}_{start_date.isoformat()}_{end_date.isoformat()}_{interval}_{unit}_{partial}" + else: + # Use days parameter + start_date = datetime.datetime.now(pytz.UTC) - timedelta(days=days) + end_date = datetime.datetime.now(pytz.UTC) + days_calc = days + cache_key = f"{symbol}_{days}_{interval}_{unit}_{partial}" + # Check market data cache - cache_key = f"{symbol}_{days}_{interval}_{unit}_{partial}" cached_data = self.get_cached_market_data(cache_key) if cached_data is not None: logger.debug(LogMessages.CACHE_HIT, extra={"cache_key": cache_key}) @@ -392,18 +435,12 @@ async def get_bars( logger.debug( LogMessages.DATA_FETCH, - extra={"symbol": symbol, "days": days, "interval": interval}, + extra={"symbol": symbol, "days": days_calc, "interval": interval}, ) # Lookup instrument instrument = await self.get_instrument(symbol) - # Calculate date range - from datetime import timedelta - - start_date = datetime.datetime.now(pytz.UTC) - timedelta(days=days) - end_date = datetime.datetime.now(pytz.UTC) - # Calculate limit based on unit type if limit is None: if unit == 1: # Seconds diff --git a/src/project_x_py/indicators/__init__.py b/src/project_x_py/indicators/__init__.py index c9c218c..f141e7b 100644 --- a/src/project_x_py/indicators/__init__.py +++ b/src/project_x_py/indicators/__init__.py @@ -202,7 +202,7 @@ ) # Version info -__version__ = "3.1.4" +__version__ = "3.1.5" __author__ = "TexasCoding" diff --git a/tests/client/test_market_data.py b/tests/client/test_market_data.py index 6eea1bc..1af8c4e 100644 --- a/tests/client/test_market_data.py +++ b/tests/client/test_market_data.py @@ -1,10 +1,12 @@ """Tests for the market data functionality of ProjectX client.""" +import datetime import time from unittest.mock import patch import polars as pl import pytest +import pytz from project_x_py import ProjectX from project_x_py.exceptions import ProjectXInstrumentError @@ -493,3 +495,246 @@ def request_matcher(**kwargs): # Different cache keys should be used assert "MGC_30_1_4_True" in client._opt_market_data_cache assert "MGC_7_1_3_True" in client._opt_market_data_cache + + @pytest.mark.asyncio + async def test_get_bars_with_start_and_end_time( + self, + mock_httpx_client, + mock_auth_response, + mock_instrument_response, + mock_bars_response, + ): + """Test getting bars with start_time and end_time parameters.""" + auth_response, accounts_response = mock_auth_response + mock_httpx_client.request.side_effect = [ + auth_response, # Initial auth + accounts_response, # Initial accounts + mock_instrument_response, # Instrument search + mock_bars_response, # Bars data + ] + + with patch("httpx.AsyncClient", return_value=mock_httpx_client): + async with ProjectX("testuser", "test-api-key") as client: + # Initialize required attributes + client.api_call_count = 0 + client._opt_instrument_cache = {} + client._opt_instrument_cache_time = {} + client._opt_market_data_cache = {} + client._opt_market_data_cache_time = {} + client.cache_ttl = 300 + client.last_cache_cleanup = time.time() + client.cache_hit_count = 0 + client.rate_limiter = RateLimiter(max_requests=100, window_seconds=60) + await client.authenticate() + + # Test with both start_time and end_time + start = datetime.datetime(2025, 1, 1, 9, 30) + end = datetime.datetime(2025, 1, 5, 16, 0) + + bars = await client.get_bars( + "MGC", start_time=start, end_time=end, interval=15 + ) + + # Verify dataframe structure + assert not bars.is_empty() + assert "timestamp" in bars.columns + assert "open" in bars.columns + + # Should use time-based cache key + start_utc = pytz.UTC.localize(start) + end_utc = pytz.UTC.localize(end) + cache_key = ( + f"MGC_{start_utc.isoformat()}_{end_utc.isoformat()}_15_2_True" + ) + assert cache_key in client._opt_market_data_cache + + @pytest.mark.asyncio + async def test_get_bars_with_only_start_time( + self, + mock_httpx_client, + mock_auth_response, + mock_instrument_response, + mock_bars_response, + ): + """Test getting bars with only start_time parameter.""" + auth_response, accounts_response = mock_auth_response + mock_httpx_client.request.side_effect = [ + auth_response, # Initial auth + accounts_response, # Initial accounts + mock_instrument_response, # Instrument search + mock_bars_response, # Bars data + ] + + with patch("httpx.AsyncClient", return_value=mock_httpx_client): + async with ProjectX("testuser", "test-api-key") as client: + # Initialize required attributes + client.api_call_count = 0 + client._opt_instrument_cache = {} + client._opt_instrument_cache_time = {} + client._opt_market_data_cache = {} + client._opt_market_data_cache_time = {} + client.cache_ttl = 300 + client.last_cache_cleanup = time.time() + client.cache_hit_count = 0 + client.rate_limiter = RateLimiter(max_requests=100, window_seconds=60) + await client.authenticate() + + # Test with only start_time (end_time should default to now) + start = datetime.datetime(2025, 1, 1, 9, 30) + + bars = await client.get_bars("MGC", start_time=start, interval=5) + + # Verify dataframe structure + assert not bars.is_empty() + assert "timestamp" in bars.columns + + @pytest.mark.asyncio + async def test_get_bars_with_only_end_time( + self, + mock_httpx_client, + mock_auth_response, + mock_instrument_response, + mock_bars_response, + ): + """Test getting bars with only end_time parameter.""" + auth_response, accounts_response = mock_auth_response + mock_httpx_client.request.side_effect = [ + auth_response, # Initial auth + accounts_response, # Initial accounts + mock_instrument_response, # Instrument search + mock_bars_response, # Bars data + ] + + with patch("httpx.AsyncClient", return_value=mock_httpx_client): + async with ProjectX("testuser", "test-api-key") as client: + # Initialize required attributes + client.api_call_count = 0 + client._opt_instrument_cache = {} + client._opt_instrument_cache_time = {} + client._opt_market_data_cache = {} + client._opt_market_data_cache_time = {} + client.cache_ttl = 300 + client.last_cache_cleanup = time.time() + client.cache_hit_count = 0 + client.rate_limiter = RateLimiter(max_requests=100, window_seconds=60) + await client.authenticate() + + # Test with only end_time (start_time should default to days ago) + end = datetime.datetime(2025, 1, 5, 16, 0) + + bars = await client.get_bars( + "MGC", + end_time=end, + days=3, # Should use this for start_time calculation + interval=60, + ) + + # Verify dataframe structure + assert not bars.is_empty() + assert "timestamp" in bars.columns + + @pytest.mark.asyncio + async def test_get_bars_with_timezone_aware_times( + self, + mock_httpx_client, + mock_auth_response, + mock_instrument_response, + mock_bars_response, + ): + """Test getting bars with timezone-aware datetime parameters.""" + auth_response, accounts_response = mock_auth_response + mock_httpx_client.request.side_effect = [ + auth_response, # Initial auth + accounts_response, # Initial accounts + mock_instrument_response, # Instrument search + mock_bars_response, # Bars data + ] + + with patch("httpx.AsyncClient", return_value=mock_httpx_client): + async with ProjectX("testuser", "test-api-key") as client: + # Initialize required attributes + client.api_call_count = 0 + client._opt_instrument_cache = {} + client._opt_instrument_cache_time = {} + client._opt_market_data_cache = {} + client._opt_market_data_cache_time = {} + client.cache_ttl = 300 + client.last_cache_cleanup = time.time() + client.cache_hit_count = 0 + client.rate_limiter = RateLimiter(max_requests=100, window_seconds=60) + await client.authenticate() + + # Test with timezone-aware datetimes + chicago_tz = pytz.timezone("America/Chicago") + start = chicago_tz.localize(datetime.datetime(2025, 1, 1, 9, 30)) + end = chicago_tz.localize(datetime.datetime(2025, 1, 5, 16, 0)) + + bars = await client.get_bars( + "MGC", start_time=start, end_time=end, interval=30 + ) + + # Verify dataframe structure + assert not bars.is_empty() + assert "timestamp" in bars.columns + + # Cache key should use UTC times + start_utc = start.astimezone(pytz.UTC) + end_utc = end.astimezone(pytz.UTC) + cache_key = ( + f"MGC_{start_utc.isoformat()}_{end_utc.isoformat()}_30_2_True" + ) + assert cache_key in client._opt_market_data_cache + + @pytest.mark.asyncio + async def test_get_bars_time_params_override_days( + self, + mock_httpx_client, + mock_auth_response, + mock_instrument_response, + mock_bars_response, + ): + """Test that start_time/end_time override the days parameter.""" + auth_response, accounts_response = mock_auth_response + mock_httpx_client.request.side_effect = [ + auth_response, # Initial auth + accounts_response, # Initial accounts + mock_instrument_response, # Instrument search + mock_bars_response, # Bars data + ] + + with patch("httpx.AsyncClient", return_value=mock_httpx_client): + async with ProjectX("testuser", "test-api-key") as client: + # Initialize required attributes + client.api_call_count = 0 + client._opt_instrument_cache = {} + client._opt_instrument_cache_time = {} + client._opt_market_data_cache = {} + client._opt_market_data_cache_time = {} + client.cache_ttl = 300 + client.last_cache_cleanup = time.time() + client.cache_hit_count = 0 + client.rate_limiter = RateLimiter(max_requests=100, window_seconds=60) + await client.authenticate() + + # Test that time params override days + start = datetime.datetime(2025, 1, 1, 9, 30) + end = datetime.datetime(2025, 1, 2, 16, 0) + + bars = await client.get_bars( + "MGC", + days=100, # This should be ignored + start_time=start, + end_time=end, + interval=15, + ) + + # Verify that the cache key uses the time range, not days + start_utc = pytz.UTC.localize(start) + end_utc = pytz.UTC.localize(end) + time_based_key = ( + f"MGC_{start_utc.isoformat()}_{end_utc.isoformat()}_15_2_True" + ) + days_based_key = "MGC_100_15_2_True" + + assert time_based_key in client._opt_market_data_cache + assert days_based_key not in client._opt_market_data_cache diff --git a/uv.lock b/uv.lock index 637496e..fc072c6 100644 --- a/uv.lock +++ b/uv.lock @@ -943,7 +943,7 @@ wheels = [ [[package]] name = "project-x-py" -version = "3.1.3" +version = "3.1.4" source = { editable = "." } dependencies = [ { name = "cachetools" },