From e9fd86cc045a7b619ccb5bba22a484d0a9db72f7 Mon Sep 17 00:00:00 2001 From: Sasha Malahov Date: Fri, 14 Aug 2026 00:34:17 -0400 Subject: [PATCH] feat: implement StateBuilder class for state vector construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add StateBuilder class with configurable hourly/daily/weekly windows - Add build_state() returning numpy array shape (1, N) - Add _normalize_window() — divide by last price minus 1 - Add _pad_window() — pad if insufficient history - Add 35 tests for StateBuilder construction, state building, normalization, padding - All 446 tests pass, ruff clean, mypy clean --- alloc/models/data.py | 146 ++++++++++++++- tests/test_state_builder.py | 364 ++++++++++++++++++++++++++++++++++++ tickets/TICKET-028.md | 26 +++ tickets/TICKET-029.md | 24 +++ 4 files changed, 558 insertions(+), 2 deletions(-) create mode 100644 tests/test_state_builder.py create mode 100644 tickets/TICKET-028.md create mode 100644 tickets/TICKET-029.md diff --git a/alloc/models/data.py b/alloc/models/data.py index 6d39410..7145bad 100644 --- a/alloc/models/data.py +++ b/alloc/models/data.py @@ -11,6 +11,148 @@ logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# StateBuilder class +# --------------------------------------------------------------------------- + +class StateBuilder: + """Build fixed-dimension state vectors from multi-frequency price data. + + Parameters + ---------- + hourly_window : int + Number of hourly bars to include per ticker (default 168 = 1 week). + daily_window : int + Number of daily bars to include per ticker (default 365 = 1 year). + weekly_window : int + Number of weekly bars to include per ticker (default 52 = 1 year). + """ + + def __init__( + self, + hourly_window: int = 168, + daily_window: int = 365, + weekly_window: int = 52, + ) -> None: + self.hourly_window = hourly_window + self.daily_window = daily_window + self.weekly_window = weekly_window + + def _normalize_window(self, prices: list[float]) -> list[float]: + """Normalise *prices* by dividing each by the last price, then subtract 1. + + The result expresses each price as a fractional change relative to + the most recent price. The last element is always ``0.0``. + + Parameters + ---------- + prices : list[float] + Raw price series (ordered oldest -> newest). + + Returns + ------- + list[float] + Normalised series. Returns ``[0.0] * len(prices)`` when the + last price is zero or the list is empty. + """ + if not prices: + return [] + + last = prices[-1] + if last == 0.0: + return [0.0] * len(prices) + + return [(p / last) - 1.0 for p in prices] + + def _pad_window(self, prices: list[float], target_length: int) -> list[float]: + """Pad *prices* with leading zeros so the result has *target_length* elements. + + If *prices* already has >= *target_length* elements, only the last + *target_length* values are returned (no truncation of the newest data). + + Parameters + ---------- + prices : list[float] + Price series (ordered oldest -> newest). + target_length : int + Desired length of the output window. + + Returns + ------- + list[float] + Padded / truncated series of exactly *target_length* elements. + """ + if target_length <= 0: + return [] + + if len(prices) >= target_length: + return prices[-target_length:] + + pad_len = target_length - len(prices) + return [0.0] * pad_len + prices + + def build_state( + self, + price_data: dict[str, dict[str, list[float]]], + allocation: list[float], + ) -> np.ndarray: + """Build a state vector from multi-frequency price data. + + For each ticker the last *N* prices per frequency are taken, + normalised (divide by last price minus 1), padded if necessary, + and concatenated. The current allocation percentages are appended + at the end. + + Parameters + ---------- + price_data : dict + ``{ticker: {"hourly": [float], "daily": [float], "weekly": [float]}}`` + allocation : list[float] + Current portfolio weights (one per ticker). + + Returns + ------- + np.ndarray + 2-D float64 array of shape ``(1, N)`` where + ``N = len(tickers) * (hourly_window + daily_window + weekly_window) + + len(allocation)``. + """ + tickers = sorted(price_data.keys()) + parts: list[float] = [] + + for ticker in tickers: + freq_data = price_data.get(ticker, {}) + hourly = freq_data.get("hourly", []) + daily = freq_data.get("daily", []) + weekly = freq_data.get("weekly", []) + + # Normalise then pad each frequency window + parts.extend( + self._pad_window( + self._normalize_window(hourly), self.hourly_window + ) + ) + parts.extend( + self._pad_window( + self._normalize_window(daily), self.daily_window + ) + ) + parts.extend( + self._pad_window( + self._normalize_window(weekly), self.weekly_window + ) + ) + + # Append allocation + parts.extend(allocation) + + return np.array(parts, dtype=np.float64).reshape(1, -1) + + +# --------------------------------------------------------------------------- +# Legacy function -- kept for backward compatibility +# --------------------------------------------------------------------------- + def get_multi_asset_data( tickers: list[str], client: Any, @@ -26,7 +168,7 @@ def get_multi_asset_data( tickers : list[str] Ticker symbols to fetch. client : Any - PolygonClient (or compatible) instance — injected, never a + PolygonClient (or compatible) instance -- injected, never a module-level singleton. end_date : datetime, optional End date for the query window. Defaults to ``datetime.today()``. @@ -102,7 +244,7 @@ def get_multi_asset_data( # --------------------------------------------------------------------------- -# State vector construction +# State vector construction (legacy) # --------------------------------------------------------------------------- def build_state_vector( diff --git a/tests/test_state_builder.py b/tests/test_state_builder.py new file mode 100644 index 0000000..0df4c0e --- /dev/null +++ b/tests/test_state_builder.py @@ -0,0 +1,364 @@ +"""Tests for alloc.models.data.StateBuilder. + +Covers construction, normalisation, padding, and full state-building +with edge cases (empty data, zero prices, insufficient history). +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from alloc.models.data import StateBuilder + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture() +def builder() -> StateBuilder: + """Default StateBuilder with standard window sizes.""" + return StateBuilder() + + +@pytest.fixture() +def builder_small() -> StateBuilder: + """StateBuilder with small windows for easy manual verification.""" + return StateBuilder(hourly_window=3, daily_window=5, weekly_window=2) + + +@pytest.fixture() +def sample_price_data() -> dict[str, dict[str, list[float]]]: + """Two-ticker price data with enough history for small windows.""" + return { + "AAPL": { + "hourly": [100.0, 101.0, 102.0, 103.0, 104.0], + "daily": [90.0, 92.0, 94.0, 96.0, 98.0, 100.0], + "weekly": [80.0, 85.0, 90.0], + }, + "MSFT": { + "hourly": [200.0, 202.0, 204.0, 206.0, 208.0], + "daily": [180.0, 185.0, 190.0, 195.0, 200.0, 205.0], + "weekly": [170.0, 180.0, 190.0], + }, + } + + +# ===================================================================== +# __init__ +# ===================================================================== + +class TestStateBuilderInit: + """Tests for StateBuilder construction.""" + + def test_default_windows(self) -> None: + b = StateBuilder() + assert b.hourly_window == 168 + assert b.daily_window == 365 + assert b.weekly_window == 52 + + def test_custom_windows(self) -> None: + b = StateBuilder(hourly_window=10, daily_window=20, weekly_window=30) + assert b.hourly_window == 10 + assert b.daily_window == 20 + assert b.weekly_window == 30 + + def test_zero_window(self) -> None: + b = StateBuilder(hourly_window=0, daily_window=0, weekly_window=0) + assert b.hourly_window == 0 + assert b.daily_window == 0 + assert b.weekly_window == 0 + + def test_single_custom_window(self) -> None: + b = StateBuilder(hourly_window=50) + assert b.hourly_window == 50 + assert b.daily_window == 365 # default + assert b.weekly_window == 52 # default + + +# ===================================================================== +# _normalize_window +# ===================================================================== + +class TestNormalizeWindow: + """Tests for StateBuilder._normalize_window.""" + + def test_basic_normalization(self, builder_small: StateBuilder) -> None: + prices = [100.0, 110.0, 120.0] + result = builder_small._normalize_window(prices) + # (100/120)-1 = -0.1666..., (110/120)-1 = -0.0833..., (120/120)-1 = 0.0 + assert len(result) == 3 + assert abs(result[0] - (-1 / 6)) < 1e-10 + assert abs(result[1] - (-1 / 12)) < 1e-10 + assert abs(result[2] - 0.0) < 1e-10 + + def test_last_element_is_zero(self, builder_small: StateBuilder) -> None: + prices = [50.0, 60.0, 70.0, 80.0] + result = builder_small._normalize_window(prices) + assert abs(result[-1] - 0.0) < 1e-10 + + def test_single_price(self, builder_small: StateBuilder) -> None: + prices = [100.0] + result = builder_small._normalize_window(prices) + assert result == [0.0] + + def test_empty_prices(self, builder_small: StateBuilder) -> None: + result = builder_small._normalize_window([]) + assert result == [] + + def test_zero_last_price(self, builder_small: StateBuilder) -> None: + prices = [100.0, 50.0, 0.0] + result = builder_small._normalize_window(prices) + assert result == [0.0, 0.0, 0.0] + + def test_all_zero_prices(self, builder_small: StateBuilder) -> None: + prices = [0.0, 0.0, 0.0] + result = builder_small._normalize_window(prices) + assert result == [0.0, 0.0, 0.0] + + def test_preserves_length(self, builder_small: StateBuilder) -> None: + prices = [10.0, 20.0, 30.0, 40.0, 50.0] + result = builder_small._normalize_window(prices) + assert len(result) == len(prices) + + def test_negative_returns(self, builder_small: StateBuilder) -> None: + prices = [200.0, 150.0, 100.0] + result = builder_small._normalize_window(prices) + assert result[0] == 1.0 # (200/100) - 1 + assert result[1] == 0.5 # (150/100) - 1 + assert abs(result[2] - 0.0) < 1e-10 + + def test_no_nan_or_inf(self, builder_small: StateBuilder) -> None: + prices = [1.0, 2.0, 3.0, 4.0, 5.0] + result = builder_small._normalize_window(prices) + assert not np.isnan(result).any() + assert not np.isinf(result).any() + + +# ===================================================================== +# _pad_window +# ===================================================================== + +class TestPadWindow: + """Tests for StateBuilder._pad_window.""" + + def test_no_padding_needed(self, builder_small: StateBuilder) -> None: + prices = [1.0, 2.0, 3.0] + result = builder_small._pad_window(prices, 3) + assert result == [1.0, 2.0, 3.0] + + def test_pad_with_zeros(self, builder_small: StateBuilder) -> None: + prices = [1.0, 2.0] + result = builder_small._pad_window(prices, 5) + assert result == [0.0, 0.0, 0.0, 1.0, 2.0] + + def test_truncate_to_target(self, builder_small: StateBuilder) -> None: + prices = [1.0, 2.0, 3.0, 4.0, 5.0] + result = builder_small._pad_window(prices, 3) + assert result == [3.0, 4.0, 5.0] + + def test_exact_length(self, builder_small: StateBuilder) -> None: + prices = [1.0, 2.0, 3.0] + result = builder_small._pad_window(prices, 3) + assert result == [1.0, 2.0, 3.0] + + def test_empty_prices_padded(self, builder_small: StateBuilder) -> None: + result = builder_small._pad_window([], 4) + assert result == [0.0, 0.0, 0.0, 0.0] + + def test_target_length_zero(self, builder_small: StateBuilder) -> None: + prices = [1.0, 2.0] + result = builder_small._pad_window(prices, 0) + assert result == [] + + def test_preserves_order(self, builder_small: StateBuilder) -> None: + prices = [10.0, 20.0] + result = builder_small._pad_window(prices, 5) + # Leading zeros, then original order preserved + assert result[-2:] == [10.0, 20.0] + + def test_large_padding(self, builder_small: StateBuilder) -> None: + prices = [1.0] + result = builder_small._pad_window(prices, 100) + assert len(result) == 100 + assert result[0] == 0.0 + assert result[-1] == 1.0 + + +# ===================================================================== +# build_state +# ===================================================================== + +class TestBuildState: + """Tests for StateBuilder.build_state.""" + + def test_returns_numpy_array( + self, builder_small: StateBuilder, sample_price_data: dict + ) -> None: + state = builder_small.build_state(sample_price_data, [0.5, 0.5]) + assert isinstance(state, np.ndarray) + + def test_shape_is_1d_batch( + self, builder_small: StateBuilder, sample_price_data: dict + ) -> None: + state = builder_small.build_state(sample_price_data, [0.5, 0.5]) + assert state.shape[0] == 1 + + def test_shape_width_correct( + self, builder_small: StateBuilder, sample_price_data: dict + ) -> None: + # 2 tickers * (3 hourly + 5 daily + 2 weekly) + 2 allocation = 20 + expected_n = 2 * (3 + 5 + 2) + 2 + state = builder_small.build_state(sample_price_data, [0.5, 0.5]) + assert state.shape == (1, expected_n) + + def test_single_ticker(self, builder_small: StateBuilder) -> None: + data = { + "AAPL": { + "hourly": [100.0, 101.0, 102.0], + "daily": [90.0, 92.0, 94.0, 96.0, 98.0], + "weekly": [80.0, 85.0], + }, + } + state = builder_small.build_state(data, [1.0]) + expected_n = 1 * (3 + 5 + 2) + 1 + assert state.shape == (1, expected_n) + + def test_allocation_appended(self, builder_small: StateBuilder) -> None: + data = { + "AAPL": { + "hourly": [100.0, 101.0, 102.0], + "daily": [90.0, 92.0, 94.0, 96.0, 98.0], + "weekly": [80.0, 85.0], + }, + } + state = builder_small.build_state(data, [1.0]) + assert state[0, -1] == 1.0 + + def test_two_ticker_allocation( + self, builder_small: StateBuilder, sample_price_data: dict + ) -> None: + state = builder_small.build_state(sample_price_data, [0.3, 0.7]) + assert state[0, -2] == 0.3 + assert state[0, -1] == 0.7 + + def test_normalization_in_state(self, builder_small: StateBuilder) -> None: + data = { + "AAPL": { + "hourly": [100.0, 200.0, 300.0], + "daily": [10.0, 20.0, 30.0, 40.0, 50.0], + "weekly": [5.0, 10.0], + }, + } + state = builder_small.build_state(data, [1.0]) + # Hourly: (100/300)-1=-0.6667, (200/300)-1=-0.3333, (300/300)-1=0.0 + assert abs(state[0, 0] - (-2 / 3)) < 1e-10 + assert abs(state[0, 1] - (-1 / 3)) < 1e-10 + assert abs(state[0, 2] - 0.0) < 1e-10 + + def test_padding_in_state(self, builder_small: StateBuilder) -> None: + data = { + "AAPL": { + "hourly": [100.0], # only 1 bar, window=3 + "daily": [10.0, 20.0], # only 2 bars, window=5 + "weekly": [5.0], # only 1 bar, window=2 + }, + } + state = builder_small.build_state(data, [1.0]) + # Hourly: pad 2 zeros + normalize [100] -> [0.0, 0.0, 0.0] + assert state[0, 0] == 0.0 + assert state[0, 1] == 0.0 + assert state[0, 2] == 0.0 # (100/100)-1 = 0.0 + + def test_empty_data(self, builder_small: StateBuilder) -> None: + data = { + "AAPL": { + "hourly": [], + "daily": [], + "weekly": [], + }, + } + state = builder_small.build_state(data, [1.0]) + expected_n = 1 * (3 + 5 + 2) + 1 + assert state.shape == (1, expected_n) + # All zeros except allocation + assert state[0, -1] == 1.0 + assert np.all(state[0, :-1] == 0.0) + + def test_zero_prices_no_crash(self, builder_small: StateBuilder) -> None: + data = { + "AAPL": { + "hourly": [0.0, 0.0, 0.0], + "daily": [0.0, 0.0, 0.0, 0.0, 0.0], + "weekly": [0.0, 0.0], + }, + } + state = builder_small.build_state(data, [1.0]) + assert not np.isnan(state).any() + assert not np.isinf(state).any() + + def test_dtype_float64( + self, builder_small: StateBuilder, sample_price_data: dict + ) -> None: + state = builder_small.build_state(sample_price_data, [0.5, 0.5]) + assert state.dtype == np.float64 + + def test_ticker_order_sorted(self, builder_small: StateBuilder) -> None: + """Tickers are sorted alphabetically for deterministic ordering.""" + data = { + "MSFT": { + "hourly": [200.0, 201.0, 202.0], + "daily": [180.0, 185.0, 190.0, 195.0, 200.0], + "weekly": [170.0, 180.0], + }, + "AAPL": { + "hourly": [100.0, 101.0, 102.0], + "daily": [90.0, 92.0, 94.0, 96.0, 98.0], + "weekly": [80.0, 85.0], + }, + } + state = builder_small.build_state(data, [0.5, 0.5]) + # AAPL comes first (alphabetical), its hourly normalized: + # (100/102)-1, (101/102)-1, (102/102)-1 + assert abs(state[0, 0] - (-2 / 102)) < 1e-10 + + def test_default_builder_large_windows( + self, builder: StateBuilder + ) -> None: + """With default large windows, small data gets heavily padded.""" + data = { + "AAPL": { + "hourly": [100.0, 101.0], + "daily": [90.0, 91.0], + "weekly": [80.0], + }, + } + state = builder.build_state(data, [1.0]) + expected_n = 1 * (168 + 365 + 52) + 1 + assert state.shape == (1, expected_n) + + def test_three_tickers(self, builder_small: StateBuilder) -> None: + data = { + "GOOGL": { + "hourly": [1.0, 2.0, 3.0], + "daily": [1.0, 2.0, 3.0, 4.0, 5.0], + "weekly": [1.0, 2.0], + }, + "AAPL": { + "hourly": [10.0, 20.0, 30.0], + "daily": [10.0, 20.0, 30.0, 40.0, 50.0], + "weekly": [10.0, 20.0], + }, + "MSFT": { + "hourly": [100.0, 200.0, 300.0], + "daily": [100.0, 200.0, 300.0, 400.0, 500.0], + "weekly": [100.0, 200.0], + }, + } + state = builder_small.build_state(data, [0.33, 0.33, 0.34]) + expected_n = 3 * (3 + 5 + 2) + 3 + assert state.shape == (1, expected_n) + # Last 3 elements are allocation + assert abs(state[0, -3] - 0.33) < 1e-10 + assert abs(state[0, -2] - 0.33) < 1e-10 + assert abs(state[0, -1] - 0.34) < 1e-10 diff --git a/tickets/TICKET-028.md b/tickets/TICKET-028.md new file mode 100644 index 0000000..7e8ac32 --- /dev/null +++ b/tickets/TICKET-028.md @@ -0,0 +1,26 @@ +# TICKET-028: Create StateBuilder class in alloc/models/data.py + +**Module:** `alloc/models/data.py` +**Priority:** High — state construction is needed by the simulation loop + +## Problem + +The seed's state construction is a procedural function with 12 parameters. We need a `StateBuilder` class with configurable window sizes, cleaner API, no pandas dependency. + +## What to Implement + +Create `StateBuilder` class in `alloc/models/data.py`: +- `__init__(self, hourly_window: int = 168, daily_window: int = 365, weekly_window: int = 52)` +- `build_state(self, price_data: dict, allocation: list[float]) -> np.ndarray` — shape (1, N) +- `_normalize_window(self, prices: list[float]) -> list[float]` — divide by last price minus 1 +- `_pad_window(self, prices: list[float], target_length: int) -> list[float]` — pad if insufficient history + +## Dependencies + +None + +## Verification + +- `pytest tests/test_data.py -xvs` — all tests pass +- `ruff check alloc/models/data.py` — clean +- `mypy alloc/models/data.py --ignore-missing-imports` — clean diff --git a/tickets/TICKET-029.md b/tickets/TICKET-029.md new file mode 100644 index 0000000..f03a3f6 --- /dev/null +++ b/tickets/TICKET-029.md @@ -0,0 +1,24 @@ +# TICKET-029: Create tests/test_state_builder.py + +**Module:** `tests/test_state_builder.py` (new) +**Priority:** High — tests for StateBuilder class + +## What to Implement + +Create `tests/test_state_builder.py` with: +- StateBuilder construction with default/custom windows +- build_state with complete data +- build_state with insufficient history (padding) +- normalize_window correctness +- pad_window correctness +- State shape verification (1, N) + +## Dependencies + +TICKET-028 + +## Verification + +- `pytest tests/test_state_builder.py -xvs` — all tests pass +- `ruff check tests/test_state_builder.py` — clean +- `mypy tests/test_state_builder.py --ignore-missing-imports` — clean