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
52 changes: 36 additions & 16 deletions alloc/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from alloc.lib.cache import DiskCache
from alloc.lib.client import PolygonClient
from alloc.models import data as data_module
from alloc.models.data import StateBuilder
from alloc.models.networks import ActorCriticNetworks
from alloc.models.portfolio import Portfolio, calculate_portfolio_reward

Expand Down Expand Up @@ -211,6 +212,39 @@ def __init__(
self.initial_value,
)

# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------

def _build_state(
self,
multi_freq: dict[str, dict[str, list[float]]],
alloc_list: list[float],
) -> np.ndarray:
"""Build state vector using StateBuilder (TICKET-043).

Replaces the legacy ``data_pipeline.build_state_vector`` call with
the OOP ``StateBuilder`` for consistent, testable state construction.

Parameters
----------
multi_freq : dict
Multi-frequency price data from ``data_pipeline.get_multi_asset_data``.
alloc_list : list[float]
Current allocation weights per ticker.

Returns
-------
np.ndarray
1-D float64 state vector.
"""
builder = StateBuilder(
hourly_window=5,
daily_window=5,
weekly_window=5,
)
return builder.build_state(multi_freq, alloc_list)

# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
Expand Down Expand Up @@ -269,14 +303,7 @@ def run(self, trading_days: int) -> dict[str, Any]:
current_alloc_dict.get(t, 0.0) for t in self.tickers
]

state = self.data_pipeline.build_state_vector(
multi_freq,
alloc_list,
self.tickers,
n_hourly=5,
n_daily=5,
n_weekly=5,
)
state = self._build_state(multi_freq, alloc_list)

# Ensure state matches network input_dim
if state.shape[0] != self.networks.input_dim:
Expand Down Expand Up @@ -344,14 +371,7 @@ def run(self, trading_days: int) -> dict[str, Any]:
next_alloc_list = [
next_alloc_dict.get(t, 0.0) for t in self.tickers
]
next_state = self.data_pipeline.build_state_vector(
next_multi_freq,
next_alloc_list,
self.tickers,
n_hourly=5,
n_daily=5,
n_weekly=5,
)
next_state = self._build_state(next_multi_freq, next_alloc_list)
if next_state.shape[0] != self.networks.input_dim:
if next_state.shape[0] < self.networks.input_dim:
next_state = np.pad(
Expand Down
27 changes: 20 additions & 7 deletions alloc/models/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

import numpy as np

from alloc.lib.cache import cache_historical, cache_latest_prices

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -39,10 +41,11 @@ def __init__(
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.
"""Normalise *prices* by dividing each by the last price.

The result expresses each price as a fractional change relative to
the most recent price. The last element is always ``0.0``.
The result expresses each price as a fraction of the most recent
price. The last element is always ``1.0`` (matching the legacy
``build_state_vector`` normalisation for backward compatibility).

Parameters
----------
Expand All @@ -62,7 +65,7 @@ def _normalize_window(self, prices: list[float]) -> list[float]:
if last == 0.0:
return [0.0] * len(prices)

return [(p / last) - 1.0 for p in prices]
return [p / last 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.
Expand Down Expand Up @@ -113,9 +116,10 @@ def build_state(
Returns
-------
np.ndarray
2-D float64 array of shape ``(1, N)`` where
1-D float64 array of shape ``(N,)`` where
``N = len(tickers) * (hourly_window + daily_window + weekly_window)
+ len(allocation)``.
+ len(allocation)``. Matches the legacy ``build_state_vector``
output shape for drop-in replacement in the training pipeline.
"""
tickers = sorted(price_data.keys())
parts: list[float] = []
Expand Down Expand Up @@ -146,13 +150,14 @@ def build_state(
# Append allocation
parts.extend(allocation)

return np.array(parts, dtype=np.float64).reshape(1, -1)
return np.array(parts, dtype=np.float64)


# ---------------------------------------------------------------------------
# Legacy function -- kept for backward compatibility
# ---------------------------------------------------------------------------

@cache_historical()
def get_multi_asset_data(
tickers: list[str],
client: Any,
Expand Down Expand Up @@ -325,6 +330,7 @@ def _normalise(prices: list[float], n: int) -> list[float]:
# Latest prices
# ---------------------------------------------------------------------------

@cache_latest_prices()
def fetch_latest_prices(
tickers: list[str],
client: Any,
Expand All @@ -345,6 +351,8 @@ def fetch_latest_prices(
"""
prices: dict[str, float] = {}

cache_valid = True

for ticker in tickers:
formatted = ticker.upper()
try:
Expand All @@ -355,9 +363,14 @@ def fetch_latest_prices(
logger.debug("Latest price for %s: %.2f", formatted, price)
else:
prices[ticker] = 0.0
cache_valid = False
logger.warning("No valid trade for %s", formatted)
except Exception as exc:
logger.warning("Error fetching latest price for %s: %s", formatted, exc)
prices[ticker] = 0.0
cache_valid = False

if not cache_valid:
prices["__cache_valid__"] = False

return prices
25 changes: 23 additions & 2 deletions alloc/utils/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,9 @@ def __post_init__(self) -> None:
Raises
------
ValueError
If tickers list is empty, positions dict is empty, or any
position value is zero or negative.
If tickers list is empty, positions dict is empty, any
position value is zero or negative, or there is a mismatch
between the set of tickers and the set of position keys.
"""
if not self.tickers:
raise ValueError(
Expand All @@ -104,6 +105,26 @@ def __post_init__(self) -> None:
"All position values must be strictly positive."
)

# TICKET-041: Cross-validate tickers and positions
ticker_set = set(self.tickers)
position_set = set(self.positions.keys())

missing_in_positions = ticker_set - position_set
if missing_in_positions:
raise ValueError(
f"Tickers without corresponding positions: "
f"{sorted(missing_in_positions)}. "
"Every ticker must have a matching entry in positions."
)

extra_in_positions = position_set - ticker_set
if extra_in_positions:
raise ValueError(
f"Positions without corresponding tickers: "
f"{sorted(extra_in_positions)}. "
"Every position key must appear in the tickers list."
)


@dataclass
class TrainingTrial:
Expand Down
95 changes: 95 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1031,3 +1031,98 @@ def test_json_with_non_numeric_value_raises(self) -> None:
"""_json_string raises ArgumentTypeError when value is non-numeric."""
with pytest.raises(argparse.ArgumentTypeError, match="must be numeric"):
_json_string('{"AAPL": "not_a_number"}')


# ===================================================================
# TICKET-041: Ticker-position consistency validation
# ===================================================================


class TestTickerPositionConsistency:
"""Tests for TICKET-041: ticker-position cross-validation in TrainingConfig."""

def test_matching_tickers_and_positions(self) -> None:
"""Matching tickers and positions should pass validation."""
config = TrainingConfig(
tickers=["AAPL", "MSFT"],
positions={"AAPL": 50000.0, "MSFT": 50000.0},
)
assert config.tickers == ["AAPL", "MSFT"]

def test_ticker_missing_from_positions(self) -> None:
"""Ticker without a corresponding position should raise ValueError."""
with pytest.raises(ValueError, match="Tickers without corresponding positions"):
TrainingConfig(
tickers=["AAPL", "MSFT", "GOOG"],
positions={"AAPL": 50000.0, "MSFT": 50000.0},
)

def test_position_extra_ticker_not_in_tickers(self) -> None:
"""Position key not in tickers list should raise ValueError."""
with pytest.raises(ValueError, match="Positions without corresponding tickers"):
TrainingConfig(
tickers=["AAPL", "MSFT"],
positions={"AAPL": 50000.0, "MSFT": 50000.0, "GOOG": 30000.0},
)

def test_empty_tickers_raises(self) -> None:
"""Empty tickers list should raise ValueError."""
with pytest.raises(ValueError, match="Tickers list is empty"):
TrainingConfig(
tickers=[],
positions={"AAPL": 50000.0},
)

def test_empty_positions_raises(self) -> None:
"""Empty positions dict should raise ValueError."""
with pytest.raises(ValueError, match="Positions dictionary is empty"):
TrainingConfig(
tickers=["AAPL"],
positions={},
)

def test_zero_position_value_raises(self) -> None:
"""Zero position value should raise ValueError."""
with pytest.raises(ValueError, match="Position value.*is 0"):
TrainingConfig(
tickers=["AAPL"],
positions={"AAPL": 0.0},
)

def test_negative_position_value_raises(self) -> None:
"""Negative position value should raise ValueError."""
with pytest.raises(ValueError, match="Position value.*is -100"):
TrainingConfig(
tickers=["AAPL"],
positions={"AAPL": -100.0},
)

def test_invalid_json_positions_in_build_config(self) -> None:
"""build_config should raise ValueError for non-dict positions_values."""
args = argparse.Namespace(
ticker_list=["AAPL"],
positions_values="not a dict",
iterations=1,
update_iterations=1,
trading_days=222,
batch_size=22,
min_allocation=0.001,
concentration_penalty=0.001,
transaction_cost=0.0,
risk_aversion=0.001,
min_cash_allocation=0.05,
target_sharpe=2.1,
target_value=220000.0,
target_outperformance=15.0,
)
with pytest.raises(ValueError, match="Positions must be a JSON object"):
build_config(args)

def test_both_mismatch_raises_first_error(self) -> None:
"""When both tickers missing from positions AND extra positions exist,
the missing-from-positions error is raised first."""
with pytest.raises(ValueError, match="Tickers without corresponding positions"):
TrainingConfig(
tickers=["AAPL", "MSFT", "GOOG"],
positions={"AAPL": 50000.0, "TSLA": 30000.0},
)
Loading
Loading