portfolio-optimization-python / tests /test_business_metrics.py
blackopsrepl's picture
.
d9f5c15
"""
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)
# All in one sector: HHI = 1.0^2 = 1.0
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)
# 50% in each sector: HHI = 0.5^2 + 0.5^2 = 0.5
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)
# 25% in each sector: HHI = 4 * 0.25^2 = 0.25
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)], # 60%
*[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(4)], # 40%
]
plan = create_plan(stocks)
# HHI = 0.6^2 + 0.4^2 = 0.36 + 0.16 = 0.52
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)
# 1 - HHI = 1 - 0.25 = 0.75
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)], # 70%
*[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(3)], # 30%
]
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), # 10%
create_stock("STK2", predicted_return=0.20), # 20%
]
plan = create_plan(stocks)
# Equal weight: (0.10 + 0.20) / 2 = 0.15
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)
# Mean = 0.125, variance = ((0.05-0.125)^2 + (0.10-0.125)^2 + (0.15-0.125)^2 + (0.20-0.125)^2) / 4
# = (0.005625 + 0.000625 + 0.000625 + 0.005625) / 4 = 0.003125
# Std dev = sqrt(0.003125) ≈ 0.0559
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)
# Return = 0.125, volatility = 0.0559
# Sharpe = 0.125 / 0.0559 ≈ 2.24
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