| | """
|
| | Tests for business metrics in the Portfolio Optimization quickstart.
|
| |
|
| | These tests verify the financial KPIs calculated by the domain model:
|
| | - Herfindahl-Hirschman Index (HHI) for concentration
|
| | - Diversification score (1 - HHI)
|
| | - Max sector exposure
|
| | - Expected return
|
| | - Return volatility
|
| | - Sharpe proxy (return / volatility)
|
| |
|
| | These metrics provide business insight beyond the solver score.
|
| | """
|
| | import pytest
|
| | import math
|
| |
|
| | from portfolio_optimization.domain import (
|
| | StockSelection,
|
| | PortfolioOptimizationPlan,
|
| | PortfolioConfig,
|
| | PortfolioMetricsModel,
|
| | SELECTED,
|
| | NOT_SELECTED,
|
| | )
|
| | from portfolio_optimization.converters import plan_to_metrics
|
| |
|
| |
|
| | def create_stock(
|
| | stock_id: str,
|
| | sector: str = "Technology",
|
| | predicted_return: float = 0.10,
|
| | selected: bool = True
|
| | ) -> StockSelection:
|
| | """Create a test stock with sensible defaults."""
|
| | return StockSelection(
|
| | stock_id=stock_id,
|
| | stock_name=f"{stock_id} Corp",
|
| | sector=sector,
|
| | predicted_return=predicted_return,
|
| | selection=SELECTED if selected else NOT_SELECTED,
|
| | )
|
| |
|
| |
|
| | def create_plan(stocks: list[StockSelection]) -> PortfolioOptimizationPlan:
|
| | """Create a test plan with given stocks."""
|
| | return PortfolioOptimizationPlan(
|
| | stocks=stocks,
|
| | target_position_count=20,
|
| | max_sector_percentage=0.25,
|
| | portfolio_config=PortfolioConfig(target_count=20, max_per_sector=5, unselected_penalty=10000),
|
| | )
|
| |
|
| |
|
| | class TestHerfindahlIndex:
|
| | """Tests for the Herfindahl-Hirschman Index (HHI) calculation."""
|
| |
|
| | def test_single_sector_hhi_is_one(self) -> None:
|
| | """All stocks in one sector should have HHI = 1.0 (max concentration)."""
|
| | stocks = [create_stock(f"STK{i}", sector="Technology") for i in range(5)]
|
| | plan = create_plan(stocks)
|
| |
|
| |
|
| | assert plan.get_herfindahl_index() == 1.0
|
| |
|
| | def test_two_equal_sectors_hhi(self) -> None:
|
| | """Two sectors with equal stocks should have HHI = 0.5."""
|
| | stocks = [
|
| | *[create_stock(f"TECH{i}", sector="Technology") for i in range(5)],
|
| | *[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(5)],
|
| | ]
|
| | plan = create_plan(stocks)
|
| |
|
| |
|
| | assert abs(plan.get_herfindahl_index() - 0.5) < 0.001
|
| |
|
| | def test_four_equal_sectors_hhi(self) -> None:
|
| | """Four sectors with equal stocks should have HHI = 0.25."""
|
| | stocks = [
|
| | *[create_stock(f"TECH{i}", sector="Technology") for i in range(5)],
|
| | *[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(5)],
|
| | *[create_stock(f"FIN{i}", sector="Finance") for i in range(5)],
|
| | *[create_stock(f"NRG{i}", sector="Energy") for i in range(5)],
|
| | ]
|
| | plan = create_plan(stocks)
|
| |
|
| |
|
| | assert abs(plan.get_herfindahl_index() - 0.25) < 0.001
|
| |
|
| | def test_empty_portfolio_hhi_is_zero(self) -> None:
|
| | """Empty portfolio should have HHI = 0."""
|
| | stocks = [create_stock(f"STK{i}", selected=False) for i in range(5)]
|
| | plan = create_plan(stocks)
|
| |
|
| | assert plan.get_herfindahl_index() == 0.0
|
| |
|
| | def test_unequal_sectors_hhi(self) -> None:
|
| | """Unequal sector distribution should give correct HHI."""
|
| | stocks = [
|
| | *[create_stock(f"TECH{i}", sector="Technology") for i in range(6)],
|
| | *[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(4)],
|
| | ]
|
| | plan = create_plan(stocks)
|
| |
|
| |
|
| | assert abs(plan.get_herfindahl_index() - 0.52) < 0.001
|
| |
|
| |
|
| | class TestDiversificationScore:
|
| | """Tests for the diversification score (1 - HHI)."""
|
| |
|
| | def test_single_sector_diversification_is_zero(self) -> None:
|
| | """All stocks in one sector should have diversification = 0."""
|
| | stocks = [create_stock(f"STK{i}", sector="Technology") for i in range(5)]
|
| | plan = create_plan(stocks)
|
| |
|
| | assert plan.get_diversification_score() == 0.0
|
| |
|
| | def test_two_equal_sectors_diversification(self) -> None:
|
| | """Two equal sectors should have diversification = 0.5."""
|
| | stocks = [
|
| | *[create_stock(f"TECH{i}", sector="Technology") for i in range(5)],
|
| | *[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(5)],
|
| | ]
|
| | plan = create_plan(stocks)
|
| |
|
| | assert abs(plan.get_diversification_score() - 0.5) < 0.001
|
| |
|
| | def test_four_equal_sectors_diversification(self) -> None:
|
| | """Four equal sectors should have diversification = 0.75."""
|
| | stocks = [
|
| | *[create_stock(f"TECH{i}", sector="Technology") for i in range(5)],
|
| | *[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(5)],
|
| | *[create_stock(f"FIN{i}", sector="Finance") for i in range(5)],
|
| | *[create_stock(f"NRG{i}", sector="Energy") for i in range(5)],
|
| | ]
|
| | plan = create_plan(stocks)
|
| |
|
| |
|
| | assert abs(plan.get_diversification_score() - 0.75) < 0.001
|
| |
|
| |
|
| | class TestMaxSectorExposure:
|
| | """Tests for max sector exposure calculation."""
|
| |
|
| | def test_single_sector_max_exposure_is_one(self) -> None:
|
| | """All stocks in one sector should have max exposure = 1.0."""
|
| | stocks = [create_stock(f"STK{i}", sector="Technology") for i in range(5)]
|
| | plan = create_plan(stocks)
|
| |
|
| | assert plan.get_max_sector_exposure() == 1.0
|
| |
|
| | def test_two_equal_sectors_max_exposure(self) -> None:
|
| | """Two equal sectors should have max exposure = 0.5."""
|
| | stocks = [
|
| | *[create_stock(f"TECH{i}", sector="Technology") for i in range(5)],
|
| | *[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(5)],
|
| | ]
|
| | plan = create_plan(stocks)
|
| |
|
| | assert abs(plan.get_max_sector_exposure() - 0.5) < 0.001
|
| |
|
| | def test_unequal_sectors_max_exposure(self) -> None:
|
| | """Unequal sectors should return the larger weight."""
|
| | stocks = [
|
| | *[create_stock(f"TECH{i}", sector="Technology") for i in range(7)],
|
| | *[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(3)],
|
| | ]
|
| | plan = create_plan(stocks)
|
| |
|
| | assert abs(plan.get_max_sector_exposure() - 0.7) < 0.001
|
| |
|
| | def test_empty_portfolio_max_exposure_is_zero(self) -> None:
|
| | """Empty portfolio should have max exposure = 0."""
|
| | stocks = [create_stock(f"STK{i}", selected=False) for i in range(5)]
|
| | plan = create_plan(stocks)
|
| |
|
| | assert plan.get_max_sector_exposure() == 0.0
|
| |
|
| |
|
| | class TestSectorCount:
|
| | """Tests for sector count calculation."""
|
| |
|
| | def test_single_sector(self) -> None:
|
| | """All stocks in one sector should return count = 1."""
|
| | stocks = [create_stock(f"STK{i}", sector="Technology") for i in range(5)]
|
| | plan = create_plan(stocks)
|
| |
|
| | assert plan.get_sector_count() == 1
|
| |
|
| | def test_multiple_sectors(self) -> None:
|
| | """Stocks in multiple sectors should return correct count."""
|
| | stocks = [
|
| | create_stock("TECH1", sector="Technology"),
|
| | create_stock("HLTH1", sector="Healthcare"),
|
| | create_stock("FIN1", sector="Finance"),
|
| | create_stock("NRG1", sector="Energy"),
|
| | ]
|
| | plan = create_plan(stocks)
|
| |
|
| | assert plan.get_sector_count() == 4
|
| |
|
| | def test_empty_portfolio_sector_count_is_zero(self) -> None:
|
| | """Empty portfolio should have sector count = 0."""
|
| | stocks = [create_stock(f"STK{i}", selected=False) for i in range(5)]
|
| | plan = create_plan(stocks)
|
| |
|
| | assert plan.get_sector_count() == 0
|
| |
|
| |
|
| | class TestExpectedReturn:
|
| | """Tests for expected return calculation."""
|
| |
|
| | def test_uniform_returns(self) -> None:
|
| | """Stocks with same returns should give that return."""
|
| | stocks = [create_stock(f"STK{i}", predicted_return=0.10) for i in range(5)]
|
| | plan = create_plan(stocks)
|
| |
|
| | assert abs(plan.get_expected_return() - 0.10) < 0.001
|
| |
|
| | def test_mixed_returns(self) -> None:
|
| | """Mixed returns should give weighted average."""
|
| | stocks = [
|
| | create_stock("STK1", predicted_return=0.10),
|
| | create_stock("STK2", predicted_return=0.20),
|
| | ]
|
| | plan = create_plan(stocks)
|
| |
|
| |
|
| | assert abs(plan.get_expected_return() - 0.15) < 0.001
|
| |
|
| | def test_empty_portfolio_return_is_zero(self) -> None:
|
| | """Empty portfolio should have return = 0."""
|
| | stocks = [create_stock(f"STK{i}", selected=False) for i in range(5)]
|
| | plan = create_plan(stocks)
|
| |
|
| | assert plan.get_expected_return() == 0.0
|
| |
|
| |
|
| | class TestReturnVolatility:
|
| | """Tests for return volatility (std dev) calculation."""
|
| |
|
| | def test_uniform_returns_zero_volatility(self) -> None:
|
| | """All same returns should give volatility = 0."""
|
| | stocks = [create_stock(f"STK{i}", predicted_return=0.10) for i in range(5)]
|
| | plan = create_plan(stocks)
|
| |
|
| | assert plan.get_return_volatility() == 0.0
|
| |
|
| | def test_varied_returns_nonzero_volatility(self) -> None:
|
| | """Varied returns should give positive volatility."""
|
| | stocks = [
|
| | create_stock("STK1", predicted_return=0.05),
|
| | create_stock("STK2", predicted_return=0.10),
|
| | create_stock("STK3", predicted_return=0.15),
|
| | create_stock("STK4", predicted_return=0.20),
|
| | ]
|
| | plan = create_plan(stocks)
|
| |
|
| |
|
| |
|
| |
|
| | expected_vol = math.sqrt(0.003125)
|
| | assert abs(plan.get_return_volatility() - expected_vol) < 0.0001
|
| |
|
| | def test_single_stock_zero_volatility(self) -> None:
|
| | """Single stock should have volatility = 0 (need at least 2)."""
|
| | stocks = [create_stock("STK1", predicted_return=0.10)]
|
| | plan = create_plan(stocks)
|
| |
|
| | assert plan.get_return_volatility() == 0.0
|
| |
|
| |
|
| | class TestSharpeProxy:
|
| | """Tests for Sharpe ratio proxy calculation."""
|
| |
|
| | def test_positive_sharpe(self) -> None:
|
| | """Positive return with volatility should give positive Sharpe."""
|
| | stocks = [
|
| | create_stock("STK1", predicted_return=0.05),
|
| | create_stock("STK2", predicted_return=0.10),
|
| | create_stock("STK3", predicted_return=0.15),
|
| | create_stock("STK4", predicted_return=0.20),
|
| | ]
|
| | plan = create_plan(stocks)
|
| |
|
| |
|
| |
|
| | sharpe = plan.get_sharpe_proxy()
|
| | assert sharpe > 2.0
|
| | assert sharpe < 2.5
|
| |
|
| | def test_zero_volatility_zero_sharpe(self) -> None:
|
| | """Zero volatility should give Sharpe = 0 (undefined)."""
|
| | stocks = [create_stock(f"STK{i}", predicted_return=0.10) for i in range(5)]
|
| | plan = create_plan(stocks)
|
| |
|
| | assert plan.get_sharpe_proxy() == 0.0
|
| |
|
| | def test_empty_portfolio_zero_sharpe(self) -> None:
|
| | """Empty portfolio should have Sharpe = 0."""
|
| | stocks = [create_stock(f"STK{i}", selected=False) for i in range(5)]
|
| | plan = create_plan(stocks)
|
| |
|
| | assert plan.get_sharpe_proxy() == 0.0
|
| |
|
| |
|
| | class TestPlanToMetrics:
|
| | """Tests for the plan_to_metrics converter function."""
|
| |
|
| | def test_metrics_from_valid_portfolio(self) -> None:
|
| | """plan_to_metrics should return all metrics for valid portfolio."""
|
| | stocks = [
|
| | *[create_stock(f"TECH{i}", sector="Technology", predicted_return=0.12) for i in range(5)],
|
| | *[create_stock(f"HLTH{i}", sector="Healthcare", predicted_return=0.08) for i in range(5)],
|
| | ]
|
| | plan = create_plan(stocks)
|
| |
|
| | metrics = plan_to_metrics(plan)
|
| |
|
| | assert metrics is not None
|
| | assert isinstance(metrics, PortfolioMetricsModel)
|
| | assert metrics.sector_count == 2
|
| | assert abs(metrics.expected_return - 0.10) < 0.001
|
| | assert abs(metrics.diversification_score - 0.5) < 0.001
|
| | assert abs(metrics.herfindahl_index - 0.5) < 0.001
|
| | assert abs(metrics.max_sector_exposure - 0.5) < 0.001
|
| |
|
| | def test_metrics_from_empty_portfolio_is_none(self) -> None:
|
| | """plan_to_metrics should return None for empty portfolio."""
|
| | stocks = [create_stock(f"STK{i}", selected=False) for i in range(5)]
|
| | plan = create_plan(stocks)
|
| |
|
| | metrics = plan_to_metrics(plan)
|
| |
|
| | assert metrics is None
|
| |
|
| | def test_metrics_serialization(self) -> None:
|
| | """Metrics should serialize with camelCase aliases."""
|
| | stocks = [create_stock(f"STK{i}") for i in range(5)]
|
| | plan = create_plan(stocks)
|
| |
|
| | metrics = plan_to_metrics(plan)
|
| | assert metrics is not None
|
| |
|
| | data = metrics.model_dump(by_alias=True)
|
| | assert "expectedReturn" in data
|
| | assert "sectorCount" in data
|
| | assert "maxSectorExposure" in data
|
| | assert "herfindahlIndex" in data
|
| | assert "diversificationScore" in data
|
| | assert "returnVolatility" in data
|
| | assert "sharpeProxy" in data
|
| |
|