File size: 11,560 Bytes
d9f5c15 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 |
"""
Tests for PortfolioConfig - the constraint configuration dataclass.
PortfolioConfig holds the threshold values that constraints use:
- target_count: Number of stocks to select (default 20)
- max_per_sector: Maximum stocks per sector (default 5)
- unselected_penalty: Soft penalty per unselected stock (default 10000)
These tests verify:
1. PortfolioConfig dataclass behavior (defaults, equality, hashing)
2. Integration with converters (model_to_plan creates correct config)
3. Integration with demo_data (generate_demo_data creates correct config)
"""
import pytest
from dataclasses import FrozenInstanceError
from portfolio_optimization.domain import (
PortfolioConfig,
PortfolioOptimizationPlan,
PortfolioOptimizationPlanModel,
StockSelectionModel,
)
from portfolio_optimization.converters import model_to_plan
from portfolio_optimization.demo_data import generate_demo_data, DemoData
class TestPortfolioConfigDataclass:
"""Tests for the PortfolioConfig dataclass itself."""
def test_default_values(self) -> None:
"""PortfolioConfig should have sensible defaults."""
config = PortfolioConfig()
assert config.target_count == 20
assert config.max_per_sector == 5
assert config.unselected_penalty == 10000
def test_custom_values(self) -> None:
"""PortfolioConfig should accept custom values."""
config = PortfolioConfig(
target_count=30,
max_per_sector=8,
unselected_penalty=5000
)
assert config.target_count == 30
assert config.max_per_sector == 8
assert config.unselected_penalty == 5000
def test_equality_same_values(self) -> None:
"""Two PortfolioConfigs with same values should be equal."""
config1 = PortfolioConfig(target_count=10, max_per_sector=3, unselected_penalty=10000)
config2 = PortfolioConfig(target_count=10, max_per_sector=3, unselected_penalty=10000)
assert config1 == config2
def test_equality_different_values(self) -> None:
"""Two PortfolioConfigs with different values should not be equal."""
config1 = PortfolioConfig(target_count=10, max_per_sector=3, unselected_penalty=10000)
config2 = PortfolioConfig(target_count=20, max_per_sector=5, unselected_penalty=10000)
assert config1 != config2
def test_equality_different_penalty(self) -> None:
"""PortfolioConfigs with different penalties should not be equal."""
config1 = PortfolioConfig(target_count=20, max_per_sector=5, unselected_penalty=10000)
config2 = PortfolioConfig(target_count=20, max_per_sector=5, unselected_penalty=5000)
assert config1 != config2
def test_hash_same_values(self) -> None:
"""Two PortfolioConfigs with same values should have same hash."""
config1 = PortfolioConfig(target_count=10, max_per_sector=3, unselected_penalty=10000)
config2 = PortfolioConfig(target_count=10, max_per_sector=3, unselected_penalty=10000)
assert hash(config1) == hash(config2)
def test_hash_different_values(self) -> None:
"""Two PortfolioConfigs with different values should (likely) have different hash."""
config1 = PortfolioConfig(target_count=10, max_per_sector=3, unselected_penalty=10000)
config2 = PortfolioConfig(target_count=20, max_per_sector=5, unselected_penalty=10000)
# Hash collision is possible but unlikely
assert hash(config1) != hash(config2)
def test_usable_as_dict_key(self) -> None:
"""PortfolioConfig should be usable as a dictionary key."""
config = PortfolioConfig(target_count=15, max_per_sector=4, unselected_penalty=8000)
d = {config: "value"}
assert d[config] == "value"
def test_usable_in_set(self) -> None:
"""PortfolioConfig should be usable in a set."""
config1 = PortfolioConfig(target_count=10, max_per_sector=3, unselected_penalty=10000)
config2 = PortfolioConfig(target_count=10, max_per_sector=3, unselected_penalty=10000)
config3 = PortfolioConfig(target_count=20, max_per_sector=5, unselected_penalty=10000)
s = {config1, config2, config3}
# config1 and config2 are equal, so set should have 2 items
assert len(s) == 2
class TestPortfolioConfigInConverters:
"""Tests for PortfolioConfig creation in converters.model_to_plan()."""
def _create_plan_model(
self,
target_position_count: int = 20,
max_sector_percentage: float = 0.25
) -> PortfolioOptimizationPlanModel:
"""Helper to create a minimal plan model for testing."""
return PortfolioOptimizationPlanModel(
stocks=[
StockSelectionModel(
stock_id="TEST",
stock_name="Test Corp",
sector="Technology",
predicted_return=0.10,
selected=None
)
],
target_position_count=target_position_count,
max_sector_percentage=max_sector_percentage
)
def test_model_to_plan_creates_config(self) -> None:
"""model_to_plan should create a PortfolioConfig."""
model = self._create_plan_model()
plan = model_to_plan(model)
assert plan.portfolio_config is not None
assert isinstance(plan.portfolio_config, PortfolioConfig)
def test_model_to_plan_config_has_correct_target(self) -> None:
"""model_to_plan should set target_count from target_position_count."""
model = self._create_plan_model(target_position_count=30)
plan = model_to_plan(model)
assert plan.portfolio_config.target_count == 30
def test_model_to_plan_config_calculates_max_per_sector(self) -> None:
"""model_to_plan should calculate max_per_sector from percentage * target."""
# 25% of 20 = 5
model = self._create_plan_model(target_position_count=20, max_sector_percentage=0.25)
plan = model_to_plan(model)
assert plan.portfolio_config.max_per_sector == 5
def test_model_to_plan_config_calculates_max_per_sector_30(self) -> None:
"""max_per_sector calculation for 30 stocks at 25%."""
# 25% of 30 = 7.5 -> 7 (int)
model = self._create_plan_model(target_position_count=30, max_sector_percentage=0.25)
plan = model_to_plan(model)
assert plan.portfolio_config.max_per_sector == 7
def test_model_to_plan_config_calculates_max_per_sector_40_percent(self) -> None:
"""max_per_sector calculation for 40% sector limit."""
# 40% of 20 = 8
model = self._create_plan_model(target_position_count=20, max_sector_percentage=0.40)
plan = model_to_plan(model)
assert plan.portfolio_config.max_per_sector == 8
def test_model_to_plan_config_minimum_max_per_sector(self) -> None:
"""max_per_sector should be at least 1."""
# 5% of 10 = 0.5 -> should be clamped to 1
model = self._create_plan_model(target_position_count=10, max_sector_percentage=0.05)
plan = model_to_plan(model)
assert plan.portfolio_config.max_per_sector == 1
def test_model_to_plan_config_default_penalty(self) -> None:
"""model_to_plan should set default unselected_penalty of 10000."""
model = self._create_plan_model()
plan = model_to_plan(model)
assert plan.portfolio_config.unselected_penalty == 10000
class TestPortfolioConfigInDemoData:
"""Tests for PortfolioConfig creation in generate_demo_data()."""
def test_small_demo_creates_config(self) -> None:
"""generate_demo_data(SMALL) should create a PortfolioConfig."""
plan = generate_demo_data(DemoData.SMALL)
assert plan.portfolio_config is not None
assert isinstance(plan.portfolio_config, PortfolioConfig)
def test_large_demo_creates_config(self) -> None:
"""generate_demo_data(LARGE) should create a PortfolioConfig."""
plan = generate_demo_data(DemoData.LARGE)
assert plan.portfolio_config is not None
assert isinstance(plan.portfolio_config, PortfolioConfig)
def test_small_demo_config_values(self) -> None:
"""SMALL demo should have default config values (20 target, 5 max per sector)."""
plan = generate_demo_data(DemoData.SMALL)
assert plan.portfolio_config.target_count == 20
assert plan.portfolio_config.max_per_sector == 5
assert plan.portfolio_config.unselected_penalty == 10000
def test_large_demo_config_values(self) -> None:
"""LARGE demo should have default config values (20 target, 5 max per sector)."""
plan = generate_demo_data(DemoData.LARGE)
assert plan.portfolio_config.target_count == 20
assert plan.portfolio_config.max_per_sector == 5
assert plan.portfolio_config.unselected_penalty == 10000
def test_demo_config_matches_plan_fields(self) -> None:
"""PortfolioConfig values should match plan's target_position_count."""
plan = generate_demo_data(DemoData.SMALL)
assert plan.portfolio_config.target_count == plan.target_position_count
def test_demo_config_max_per_sector_matches_percentage(self) -> None:
"""max_per_sector should equal max_sector_percentage * target_position_count."""
plan = generate_demo_data(DemoData.SMALL)
expected = int(plan.max_sector_percentage * plan.target_position_count)
assert plan.portfolio_config.max_per_sector == expected
class TestPortfolioConfigEdgeCases:
"""Edge case tests for PortfolioConfig."""
def test_very_small_target(self) -> None:
"""PortfolioConfig should work with small target count."""
config = PortfolioConfig(target_count=5, max_per_sector=2, unselected_penalty=10000)
assert config.target_count == 5
assert config.max_per_sector == 2
def test_very_large_target(self) -> None:
"""PortfolioConfig should work with large target count."""
config = PortfolioConfig(target_count=100, max_per_sector=25, unselected_penalty=10000)
assert config.target_count == 100
assert config.max_per_sector == 25
def test_zero_penalty(self) -> None:
"""PortfolioConfig should allow zero penalty (disables selection driving)."""
config = PortfolioConfig(target_count=20, max_per_sector=5, unselected_penalty=0)
assert config.unselected_penalty == 0
def test_large_penalty(self) -> None:
"""PortfolioConfig should allow large penalties."""
config = PortfolioConfig(target_count=20, max_per_sector=5, unselected_penalty=1000000)
assert config.unselected_penalty == 1000000
def test_equality_with_non_config(self) -> None:
"""PortfolioConfig should not equal non-PortfolioConfig objects."""
config = PortfolioConfig()
assert config != "not a config"
assert config != 20
assert config != {"target_count": 20}
def test_repr(self) -> None:
"""PortfolioConfig should have a useful repr."""
config = PortfolioConfig(target_count=15, max_per_sector=4, unselected_penalty=8000)
repr_str = repr(config)
assert "15" in repr_str
assert "4" in repr_str
assert "8000" in repr_str
|