Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions src/project_x_py/client/market_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 8 additions & 4 deletions src/project_x_py/orderbook/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions src/project_x_py/sessions/statistics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
86 changes: 85 additions & 1 deletion tests/client/test_market_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
48 changes: 48 additions & 0 deletions tests/orderbook/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/test_session_statistics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading