diff --git a/CHANGELOG.md b/CHANGELOG.md index 929022d..dd6082d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Internal + +- Normalize Polars `Series.sum()` aggregates to `float`/`int` before + arithmetic so mypy is clean under polars 1.44 (`sum()` now types as a + union that includes `Decimal`). Unblocks Dependabot #154. + ## [4.4.0] - 2026-10-04 ### Changed diff --git a/src/project_x_py/client/market_data.py b/src/project_x_py/client/market_data.py index fd0a553..ca80146 100644 --- a/src/project_x_py/client/market_data.py +++ b/src/project_x_py/client/market_data.py @@ -1151,15 +1151,15 @@ async def get_session_statistics( if low_val is not None and isinstance(low_val, int | float) else 0.0 ) - session_volume = int(bars["volume"].sum()) + session_volume = int(bars["volume"].sum() or 0) - # Calculate VWAP + # Calculate VWAP. Polars 1.44 types Series.sum() as int | float | Decimal. bars_with_pv = bars.with_columns( [(pl.col("close") * pl.col("volume")).alias("price_volume")] ) - total_pv = bars_with_pv["price_volume"].sum() - total_volume = bars_with_pv["volume"].sum() - session_vwap = float(total_pv / total_volume) if total_volume > 0 else None + total_pv = float(bars_with_pv["price_volume"].sum() or 0) + total_volume = float(bars_with_pv["volume"].sum() or 0) + session_vwap = total_pv / total_volume if total_volume > 0 else None return { "session_high": session_high, diff --git a/src/project_x_py/orderbook/base.py b/src/project_x_py/orderbook/base.py index 663c029..c397712 100644 --- a/src/project_x_py/orderbook/base.py +++ b/src/project_x_py/orderbook/base.py @@ -661,12 +661,16 @@ async def get_orderbook_snapshot(self, levels: int = 10) -> OrderbookSnapshot: else [] ) - # Calculate totals - total_bid_volume = bids["volume"].sum() if not bids.is_empty() else 0 - total_ask_volume = asks["volume"].sum() if not asks.is_empty() else 0 + # Polars 1.44 types Series.sum() as int | float | Decimal + total_bid_volume = ( + int(bids["volume"].sum() or 0) if not bids.is_empty() else 0 + ) + total_ask_volume = ( + int(asks["volume"].sum() or 0) if not asks.is_empty() else 0 + ) # Calculate imbalance - imbalance = None + imbalance: float | None = None if total_bid_volume > 0 or total_ask_volume > 0: imbalance = (total_bid_volume - total_ask_volume) / ( total_bid_volume + total_ask_volume diff --git a/src/project_x_py/sessions/statistics.py b/src/project_x_py/sessions/statistics.py index 2608d9c..aac6a05 100644 --- a/src/project_x_py/sessions/statistics.py +++ b/src/project_x_py/sessions/statistics.py @@ -126,12 +126,13 @@ def _calculate_vwap(self, data: pl.DataFrame) -> float: return 0.0 # VWAP = sum(price * volume) / sum(volume) - total_volume = data["volume"].sum() + # Polars 1.44 types Series.sum() as int | float | Decimal + total_volume = float(data["volume"].sum() or 0) if total_volume == 0: return 0.0 - vwap_numerator = (data["close"] * data["volume"]).sum() - return float(vwap_numerator / total_volume) + vwap_numerator = float((data["close"] * data["volume"]).sum() or 0) + return vwap_numerator / total_volume class SessionAnalytics: diff --git a/tests/client/test_market_data.py b/tests/client/test_market_data.py index aa36d4b..64ec0db 100644 --- a/tests/client/test_market_data.py +++ b/tests/client/test_market_data.py @@ -2,7 +2,7 @@ import datetime import time -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import polars as pl import pytest @@ -955,3 +955,87 @@ async def test_get_bars_time_params_override_days( assert time_based_key in client._opt_market_data_cache assert days_based_key not in client._opt_market_data_cache + + +class TestGetSessionStatistics: + """Session VWAP / volume totals from get_session_statistics.""" + + @pytest.mark.asyncio + async def test_session_vwap_is_volume_weighted(self, initialized_client): + """VWAP is sum(close * volume) / sum(volume), returned as float.""" + bars = pl.DataFrame( + { + "timestamp": [ + datetime.datetime( + 2024, 1, 15, 14, 30, tzinfo=datetime.timezone.utc + ), + datetime.datetime( + 2024, 1, 15, 14, 31, tzinfo=datetime.timezone.utc + ), + ], + "open": [100.0, 200.0], + "high": [101.0, 201.0], + "low": [99.0, 199.0], + "close": [100.0, 200.0], + "volume": [1, 3], + } + ) + initialized_client.get_session_bars = AsyncMock(return_value=bars) + + stats = await initialized_client.get_session_statistics("MNQ") + + assert stats["session_volume"] == 4 + assert isinstance(stats["session_volume"], int) + assert stats["session_vwap"] == 175.0 + assert isinstance(stats["session_vwap"], float) + assert stats["session_high"] == 201.0 + assert stats["session_low"] == 99.0 + assert stats["session_range"] == 102.0 + + @pytest.mark.asyncio + async def test_session_statistics_empty_bars(self, initialized_client): + """Empty session bars return None VWAP and zero volume.""" + empty = pl.DataFrame( + schema={ + "timestamp": pl.Datetime(time_zone="UTC"), + "open": pl.Float64, + "high": pl.Float64, + "low": pl.Float64, + "close": pl.Float64, + "volume": pl.Int64, + } + ) + initialized_client.get_session_bars = AsyncMock(return_value=empty) + + stats = await initialized_client.get_session_statistics("MNQ") + + assert stats["session_high"] is None + assert stats["session_low"] is None + assert stats["session_volume"] == 0 + assert stats["session_vwap"] is None + assert stats["session_range"] is None + + @pytest.mark.asyncio + async def test_session_vwap_zero_volume_is_none(self, initialized_client): + """Zero total volume skips VWAP (None) rather than dividing by zero.""" + bars = pl.DataFrame( + { + "timestamp": [ + datetime.datetime(2024, 1, 15, 14, 30, tzinfo=datetime.timezone.utc) + ], + "open": [4900.0], + "high": [4910.0], + "low": [4895.0], + "close": [4905.0], + "volume": [0], + } + ) + initialized_client.get_session_bars = AsyncMock(return_value=bars) + + stats = await initialized_client.get_session_statistics("MNQ") + + assert stats["session_volume"] == 0 + assert stats["session_vwap"] is None + assert stats["session_high"] == 4910.0 + assert stats["session_low"] == 4895.0 + assert stats["session_range"] == 15.0 diff --git a/tests/orderbook/test_base.py b/tests/orderbook/test_base.py index fe87322..084680d 100644 --- a/tests/orderbook/test_base.py +++ b/tests/orderbook/test_base.py @@ -434,6 +434,54 @@ async def test_get_orderbook_snapshot_empty(self, orderbook_base): assert snapshot["total_bid_volume"] == 0 assert snapshot["total_ask_volume"] == 0 + @pytest.mark.asyncio + async def test_get_orderbook_snapshot_bids_only(self, orderbook_base): + """One-sided book (empty asks) reports full bid imbalance as 1.0.""" + ob = orderbook_base + timestamp = datetime.now(ob.timezone) + + async with ob.orderbook_lock: + ob.orderbook_bids = pl.DataFrame({ + "price": [21000.0, 20999.75], + "volume": [10, 5], + "timestamp": [timestamp, timestamp], + }) + ob.last_orderbook_update = timestamp + + snapshot = await ob.get_orderbook_snapshot(levels=2) + + assert snapshot["total_bid_volume"] == 15 + assert snapshot["total_ask_volume"] == 0 + assert snapshot["asks"] == [] + assert snapshot["imbalance"] == 1.0 + assert isinstance(snapshot["total_bid_volume"], int) + assert isinstance(snapshot["total_ask_volume"], int) + assert isinstance(snapshot["imbalance"], float) + + @pytest.mark.asyncio + async def test_get_orderbook_snapshot_asks_only(self, orderbook_base): + """One-sided book (empty bids) reports full ask imbalance as -1.0.""" + ob = orderbook_base + timestamp = datetime.now(ob.timezone) + + async with ob.orderbook_lock: + ob.orderbook_asks = pl.DataFrame({ + "price": [21000.25, 21000.50], + "volume": [8, 12], + "timestamp": [timestamp, timestamp], + }) + ob.last_orderbook_update = timestamp + + snapshot = await ob.get_orderbook_snapshot(levels=2) + + assert snapshot["total_bid_volume"] == 0 + assert snapshot["total_ask_volume"] == 20 + assert snapshot["bids"] == [] + assert snapshot["imbalance"] == -1.0 + assert isinstance(snapshot["total_bid_volume"], int) + assert isinstance(snapshot["total_ask_volume"], int) + assert isinstance(snapshot["imbalance"], float) + @pytest.mark.asyncio async def test_get_best_bid_ask(self, orderbook_base): """Test getting best bid and ask prices.""" diff --git a/tests/unit/test_session_statistics.py b/tests/unit/test_session_statistics.py index 91414b5..384c16d 100644 --- a/tests/unit/test_session_statistics.py +++ b/tests/unit/test_session_statistics.py @@ -601,6 +601,30 @@ def test_calculate_vwap_zero_volume(self, stats): # Should return 0.0 to avoid division by zero assert result == 0.0 + def test_calculate_vwap_volume_weighted_average(self, stats): + """Test _calculate_vwap uses volume weighting, not a simple mean.""" + data = pl.DataFrame({ + "close": [100.0, 200.0], + "volume": [1, 3], + }) + + result = stats._calculate_vwap(data) + + # (100*1 + 200*3) / (1+3) = 175; simple mean would be 150 + assert result == 175.0 + assert isinstance(result, float) + + def test_calculate_vwap_single_bar_equals_close(self, stats): + """Test _calculate_vwap of a single bar equals that bar's close.""" + data = pl.DataFrame({ + "close": [4905.25], + "volume": [10000], + }) + + result = stats._calculate_vwap(data) + assert result == 4905.25 + assert isinstance(result, float) + def test_calculate_volume_precision(self, stats): """Test _calculate_volume handles large numbers correctly.""" large_volume_df = pl.DataFrame({