|
|
"""
|
|
|
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
|
|
|
|