Team Ai
Apppublic

blackopsrepl/portfolio-optimization-python

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