Team Ai
Apppublic

blackopsrepl/portfolio-optimization-python

sourceHugging Faceapache-2.0updated 10mo agoView on Hugging Face
1likes
test_business_metrics.py353 linesDownload Raw Back to tests
1"""
2Tests for business metrics in the Portfolio Optimization quickstart.
3
4These tests verify the financial KPIs calculated by the domain model:
5- Herfindahl-Hirschman Index (HHI) for concentration
6- Diversification score (1 - HHI)
7- Max sector exposure
8- Expected return
9- Return volatility
10- Sharpe proxy (return / volatility)
11
12These metrics provide business insight beyond the solver score.
13"""
14import pytest
15import math
16
17from portfolio_optimization.domain import (
18    StockSelection,
19    PortfolioOptimizationPlan,
20    PortfolioConfig,
21    PortfolioMetricsModel,
22    SELECTED,
23    NOT_SELECTED,
24)
25from portfolio_optimization.converters import plan_to_metrics
26
27
28def create_stock(
29    stock_id: str,
30    sector: str = "Technology",
31    predicted_return: float = 0.10,
32    selected: bool = True
33) -> StockSelection:
34    """Create a test stock with sensible defaults."""
35    return StockSelection(
36        stock_id=stock_id,
37        stock_name=f"{stock_id} Corp",
38        sector=sector,
39        predicted_return=predicted_return,
40        selection=SELECTED if selected else NOT_SELECTED,
41    )
42
43
44def create_plan(stocks: list[StockSelection]) -> PortfolioOptimizationPlan:
45    """Create a test plan with given stocks."""
46    return PortfolioOptimizationPlan(
47        stocks=stocks,
48        target_position_count=20,
49        max_sector_percentage=0.25,
50        portfolio_config=PortfolioConfig(target_count=20, max_per_sector=5, unselected_penalty=10000),
51    )
52
53
54class TestHerfindahlIndex:
55    """Tests for the Herfindahl-Hirschman Index (HHI) calculation."""
56
57    def test_single_sector_hhi_is_one(self) -> None:
58        """All stocks in one sector should have HHI = 1.0 (max concentration)."""
59        stocks = [create_stock(f"STK{i}", sector="Technology") for i in range(5)]
60        plan = create_plan(stocks)
61
62        # All in one sector: HHI = 1.0^2 = 1.0
63        assert plan.get_herfindahl_index() == 1.0
64
65    def test_two_equal_sectors_hhi(self) -> None:
66        """Two sectors with equal stocks should have HHI = 0.5."""
67        stocks = [
68            *[create_stock(f"TECH{i}", sector="Technology") for i in range(5)],
69            *[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(5)],
70        ]
71        plan = create_plan(stocks)
72
73        # 50% in each sector: HHI = 0.5^2 + 0.5^2 = 0.5
74        assert abs(plan.get_herfindahl_index() - 0.5) < 0.001
75
76    def test_four_equal_sectors_hhi(self) -> None:
77        """Four sectors with equal stocks should have HHI = 0.25."""
78        stocks = [
79            *[create_stock(f"TECH{i}", sector="Technology") for i in range(5)],
80            *[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(5)],
81            *[create_stock(f"FIN{i}", sector="Finance") for i in range(5)],
82            *[create_stock(f"NRG{i}", sector="Energy") for i in range(5)],
83        ]
84        plan = create_plan(stocks)
85
86        # 25% in each sector: HHI = 4 * 0.25^2 = 0.25
87        assert abs(plan.get_herfindahl_index() - 0.25) < 0.001
88
89    def test_empty_portfolio_hhi_is_zero(self) -> None:
90        """Empty portfolio should have HHI = 0."""
91        stocks = [create_stock(f"STK{i}", selected=False) for i in range(5)]
92        plan = create_plan(stocks)
93
94        assert plan.get_herfindahl_index() == 0.0
95
96    def test_unequal_sectors_hhi(self) -> None:
97        """Unequal sector distribution should give correct HHI."""
98        stocks = [
99            *[create_stock(f"TECH{i}", sector="Technology") for i in range(6)],  # 60%
100            *[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(4)],  # 40%
101        ]
102        plan = create_plan(stocks)
103
104        # HHI = 0.6^2 + 0.4^2 = 0.36 + 0.16 = 0.52
105        assert abs(plan.get_herfindahl_index() - 0.52) < 0.001
106
107
108class TestDiversificationScore:
109    """Tests for the diversification score (1 - HHI)."""
110
111    def test_single_sector_diversification_is_zero(self) -> None:
112        """All stocks in one sector should have diversification = 0."""
113        stocks = [create_stock(f"STK{i}", sector="Technology") for i in range(5)]
114        plan = create_plan(stocks)
115
116        assert plan.get_diversification_score() == 0.0
117
118    def test_two_equal_sectors_diversification(self) -> None:
119        """Two equal sectors should have diversification = 0.5."""
120        stocks = [
121            *[create_stock(f"TECH{i}", sector="Technology") for i in range(5)],
122            *[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(5)],
123        ]
124        plan = create_plan(stocks)
125
126        assert abs(plan.get_diversification_score() - 0.5) < 0.001
127
128    def test_four_equal_sectors_diversification(self) -> None:
129        """Four equal sectors should have diversification = 0.75."""
130        stocks = [
131            *[create_stock(f"TECH{i}", sector="Technology") for i in range(5)],
132            *[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(5)],
133            *[create_stock(f"FIN{i}", sector="Finance") for i in range(5)],
134            *[create_stock(f"NRG{i}", sector="Energy") for i in range(5)],
135        ]
136        plan = create_plan(stocks)
137
138        # 1 - HHI = 1 - 0.25 = 0.75
139        assert abs(plan.get_diversification_score() - 0.75) < 0.001
140
141
142class TestMaxSectorExposure:
143    """Tests for max sector exposure calculation."""
144
145    def test_single_sector_max_exposure_is_one(self) -> None:
146        """All stocks in one sector should have max exposure = 1.0."""
147        stocks = [create_stock(f"STK{i}", sector="Technology") for i in range(5)]
148        plan = create_plan(stocks)
149
150        assert plan.get_max_sector_exposure() == 1.0
151
152    def test_two_equal_sectors_max_exposure(self) -> None:
153        """Two equal sectors should have max exposure = 0.5."""
154        stocks = [
155            *[create_stock(f"TECH{i}", sector="Technology") for i in range(5)],
156            *[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(5)],
157        ]
158        plan = create_plan(stocks)
159
160        assert abs(plan.get_max_sector_exposure() - 0.5) < 0.001
161
162    def test_unequal_sectors_max_exposure(self) -> None:
163        """Unequal sectors should return the larger weight."""
164        stocks = [
165            *[create_stock(f"TECH{i}", sector="Technology") for i in range(7)],  # 70%
166            *[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(3)],  # 30%
167        ]
168        plan = create_plan(stocks)
169
170        assert abs(plan.get_max_sector_exposure() - 0.7) < 0.001
171
172    def test_empty_portfolio_max_exposure_is_zero(self) -> None:
173        """Empty portfolio should have max exposure = 0."""
174        stocks = [create_stock(f"STK{i}", selected=False) for i in range(5)]
175        plan = create_plan(stocks)
176
177        assert plan.get_max_sector_exposure() == 0.0
178
179
180class TestSectorCount:
181    """Tests for sector count calculation."""
182
183    def test_single_sector(self) -> None:
184        """All stocks in one sector should return count = 1."""
185        stocks = [create_stock(f"STK{i}", sector="Technology") for i in range(5)]
186        plan = create_plan(stocks)
187
188        assert plan.get_sector_count() == 1
189
190    def test_multiple_sectors(self) -> None:
191        """Stocks in multiple sectors should return correct count."""
192        stocks = [
193            create_stock("TECH1", sector="Technology"),
194            create_stock("HLTH1", sector="Healthcare"),
195            create_stock("FIN1", sector="Finance"),
196            create_stock("NRG1", sector="Energy"),
197        ]
198        plan = create_plan(stocks)
199
200        assert plan.get_sector_count() == 4
201
202    def test_empty_portfolio_sector_count_is_zero(self) -> None:
203        """Empty portfolio should have sector count = 0."""
204        stocks = [create_stock(f"STK{i}", selected=False) for i in range(5)]
205        plan = create_plan(stocks)
206
207        assert plan.get_sector_count() == 0
208
209
210class TestExpectedReturn:
211    """Tests for expected return calculation."""
212
213    def test_uniform_returns(self) -> None:
214        """Stocks with same returns should give that return."""
215        stocks = [create_stock(f"STK{i}", predicted_return=0.10) for i in range(5)]
216        plan = create_plan(stocks)
217
218        assert abs(plan.get_expected_return() - 0.10) < 0.001
219
220    def test_mixed_returns(self) -> None:
221        """Mixed returns should give weighted average."""
222        stocks = [
223            create_stock("STK1", predicted_return=0.10),  # 10%
224            create_stock("STK2", predicted_return=0.20),  # 20%
225        ]
226        plan = create_plan(stocks)
227
228        # Equal weight: (0.10 + 0.20) / 2 = 0.15
229        assert abs(plan.get_expected_return() - 0.15) < 0.001
230
231    def test_empty_portfolio_return_is_zero(self) -> None:
232        """Empty portfolio should have return = 0."""
233        stocks = [create_stock(f"STK{i}", selected=False) for i in range(5)]
234        plan = create_plan(stocks)
235
236        assert plan.get_expected_return() == 0.0
237
238
239class TestReturnVolatility:
240    """Tests for return volatility (std dev) calculation."""
241
242    def test_uniform_returns_zero_volatility(self) -> None:
243        """All same returns should give volatility = 0."""
244        stocks = [create_stock(f"STK{i}", predicted_return=0.10) for i in range(5)]
245        plan = create_plan(stocks)
246
247        assert plan.get_return_volatility() == 0.0
248
249    def test_varied_returns_nonzero_volatility(self) -> None:
250        """Varied returns should give positive volatility."""
251        stocks = [
252            create_stock("STK1", predicted_return=0.05),
253            create_stock("STK2", predicted_return=0.10),
254            create_stock("STK3", predicted_return=0.15),
255            create_stock("STK4", predicted_return=0.20),
256        ]
257        plan = create_plan(stocks)
258
259        # 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
260        # = (0.005625 + 0.000625 + 0.000625 + 0.005625) / 4 = 0.003125
261        # Std dev = sqrt(0.003125) ≈ 0.0559
262        expected_vol = math.sqrt(0.003125)
263        assert abs(plan.get_return_volatility() - expected_vol) < 0.0001
264
265    def test_single_stock_zero_volatility(self) -> None:
266        """Single stock should have volatility = 0 (need at least 2)."""
267        stocks = [create_stock("STK1", predicted_return=0.10)]
268        plan = create_plan(stocks)
269
270        assert plan.get_return_volatility() == 0.0
271
272
273class TestSharpeProxy:
274    """Tests for Sharpe ratio proxy calculation."""
275
276    def test_positive_sharpe(self) -> None:
277        """Positive return with volatility should give positive Sharpe."""
278        stocks = [
279            create_stock("STK1", predicted_return=0.05),
280            create_stock("STK2", predicted_return=0.10),
281            create_stock("STK3", predicted_return=0.15),
282            create_stock("STK4", predicted_return=0.20),
283        ]
284        plan = create_plan(stocks)
285
286        # Return = 0.125, volatility = 0.0559
287        # Sharpe = 0.125 / 0.0559 ≈ 2.24
288        sharpe = plan.get_sharpe_proxy()
289        assert sharpe > 2.0
290        assert sharpe < 2.5
291
292    def test_zero_volatility_zero_sharpe(self) -> None:
293        """Zero volatility should give Sharpe = 0 (undefined)."""
294        stocks = [create_stock(f"STK{i}", predicted_return=0.10) for i in range(5)]
295        plan = create_plan(stocks)
296
297        assert plan.get_sharpe_proxy() == 0.0
298
299    def test_empty_portfolio_zero_sharpe(self) -> None:
300        """Empty portfolio should have Sharpe = 0."""
301        stocks = [create_stock(f"STK{i}", selected=False) for i in range(5)]
302        plan = create_plan(stocks)
303
304        assert plan.get_sharpe_proxy() == 0.0
305
306
307class TestPlanToMetrics:
308    """Tests for the plan_to_metrics converter function."""
309
310    def test_metrics_from_valid_portfolio(self) -> None:
311        """plan_to_metrics should return all metrics for valid portfolio."""
312        stocks = [
313            *[create_stock(f"TECH{i}", sector="Technology", predicted_return=0.12) for i in range(5)],
314            *[create_stock(f"HLTH{i}", sector="Healthcare", predicted_return=0.08) for i in range(5)],
315        ]
316        plan = create_plan(stocks)
317
318        metrics = plan_to_metrics(plan)
319
320        assert metrics is not None
321        assert isinstance(metrics, PortfolioMetricsModel)
322        assert metrics.sector_count == 2
323        assert abs(metrics.expected_return - 0.10) < 0.001
324        assert abs(metrics.diversification_score - 0.5) < 0.001
325        assert abs(metrics.herfindahl_index - 0.5) < 0.001
326        assert abs(metrics.max_sector_exposure - 0.5) < 0.001
327
328    def test_metrics_from_empty_portfolio_is_none(self) -> None:
329        """plan_to_metrics should return None for empty portfolio."""
330        stocks = [create_stock(f"STK{i}", selected=False) for i in range(5)]
331        plan = create_plan(stocks)
332
333        metrics = plan_to_metrics(plan)
334
335        assert metrics is None
336
337    def test_metrics_serialization(self) -> None:
338        """Metrics should serialize with camelCase aliases."""
339        stocks = [create_stock(f"STK{i}") for i in range(5)]
340        plan = create_plan(stocks)
341
342        metrics = plan_to_metrics(plan)
343        assert metrics is not None
344
345        data = metrics.model_dump(by_alias=True)
346        assert "expectedReturn" in data
347        assert "sectorCount" in data
348        assert "maxSectorExposure" in data
349        assert "herfindahlIndex" in data
350        assert "diversificationScore" in data
351        assert "returnVolatility" in data
352        assert "sharpeProxy" in data
353