|
|
"""
|
|
|
Constraint tests for the Portfolio Optimization quickstart.
|
|
|
|
|
|
Each constraint is tested with both penalizing and non-penalizing scenarios.
|
|
|
This ensures the constraints correctly encode the business rules.
|
|
|
|
|
|
Test Patterns:
|
|
|
1. Create minimal test data (just what's needed for the test)
|
|
|
2. Use constraint_verifier to check penalties/rewards
|
|
|
3. Provide PortfolioConfig as problem fact for parameterized constraints
|
|
|
|
|
|
Finance Concepts Tested:
|
|
|
- Stock selection (configurable target, default 20)
|
|
|
- Sector diversification (configurable max per sector, default 5)
|
|
|
- Return maximization (prefer high-return stocks)
|
|
|
"""
|
|
|
from solverforge_legacy.solver.test import ConstraintVerifier
|
|
|
|
|
|
from portfolio_optimization.domain import (
|
|
|
StockSelection,
|
|
|
PortfolioOptimizationPlan,
|
|
|
PortfolioConfig,
|
|
|
SelectionValue,
|
|
|
SELECTED,
|
|
|
NOT_SELECTED,
|
|
|
)
|
|
|
from portfolio_optimization.constraints import (
|
|
|
define_constraints,
|
|
|
must_select_target_count,
|
|
|
penalize_unselected_stock,
|
|
|
sector_exposure_limit,
|
|
|
maximize_expected_return,
|
|
|
)
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
constraint_verifier = ConstraintVerifier.build(
|
|
|
define_constraints, PortfolioOptimizationPlan, StockSelection
|
|
|
)
|
|
|
|
|
|
|
|
|
DEFAULT_CONFIG = PortfolioConfig(target_count=20, max_per_sector=5, unselected_penalty=10000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
Args:
|
|
|
stock_id: Unique identifier for the stock
|
|
|
sector: Industry sector (default "Technology")
|
|
|
predicted_return: ML-predicted return as decimal (default 0.10 = 10%)
|
|
|
selected: If True, stock is selected for portfolio. If False, not selected.
|
|
|
|
|
|
Returns:
|
|
|
StockSelection with the specified parameters
|
|
|
"""
|
|
|
|
|
|
selection_value = SELECTED if selected else NOT_SELECTED
|
|
|
|
|
|
return StockSelection(
|
|
|
stock_id=stock_id,
|
|
|
stock_name=f"{stock_id} Corp",
|
|
|
sector=sector,
|
|
|
predicted_return=predicted_return,
|
|
|
selection=selection_value,
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMustSelectTargetCount:
|
|
|
"""Tests for the must_select_target_count constraint.
|
|
|
|
|
|
This is a parameterized constraint that reads target_count from PortfolioConfig.
|
|
|
Default is 20 stocks. Only penalizes when count EXCEEDS target.
|
|
|
"""
|
|
|
|
|
|
def test_exactly_target_no_penalty(self) -> None:
|
|
|
"""Selecting exactly target_count stocks should not be penalized."""
|
|
|
stocks = [create_stock(f"STK{i}", selected=True) for i in range(20)]
|
|
|
|
|
|
constraint_verifier.verify_that(must_select_target_count).given(
|
|
|
*stocks, DEFAULT_CONFIG
|
|
|
).penalizes(0)
|
|
|
|
|
|
def test_one_over_target_penalizes_1(self) -> None:
|
|
|
"""Selecting target_count + 1 stocks should be penalized by 1."""
|
|
|
stocks = [create_stock(f"STK{i}", selected=True) for i in range(21)]
|
|
|
|
|
|
constraint_verifier.verify_that(must_select_target_count).given(
|
|
|
*stocks, DEFAULT_CONFIG
|
|
|
).penalizes_by(1)
|
|
|
|
|
|
def test_five_over_target_penalizes_5(self) -> None:
|
|
|
"""Selecting target_count + 5 stocks should be penalized by 5."""
|
|
|
stocks = [create_stock(f"STK{i}", selected=True) for i in range(25)]
|
|
|
|
|
|
constraint_verifier.verify_that(must_select_target_count).given(
|
|
|
*stocks, DEFAULT_CONFIG
|
|
|
).penalizes_by(5)
|
|
|
|
|
|
def test_under_target_no_penalty(self) -> None:
|
|
|
"""The max constraint doesn't penalize for too few stocks."""
|
|
|
stocks = [create_stock(f"STK{i}", selected=True) for i in range(19)]
|
|
|
|
|
|
constraint_verifier.verify_that(must_select_target_count).given(
|
|
|
*stocks, DEFAULT_CONFIG
|
|
|
).penalizes(0)
|
|
|
|
|
|
def test_custom_target_10(self) -> None:
|
|
|
"""Custom target_count=10: 11 stocks should penalize by 1."""
|
|
|
config = PortfolioConfig(target_count=10, max_per_sector=5, unselected_penalty=10000)
|
|
|
stocks = [create_stock(f"STK{i}", selected=True) for i in range(11)]
|
|
|
|
|
|
constraint_verifier.verify_that(must_select_target_count).given(
|
|
|
*stocks, config
|
|
|
).penalizes_by(1)
|
|
|
|
|
|
def test_custom_target_30(self) -> None:
|
|
|
"""Custom target_count=30: exactly 30 stocks should not penalize."""
|
|
|
config = PortfolioConfig(target_count=30, max_per_sector=8, unselected_penalty=10000)
|
|
|
stocks = [create_stock(f"STK{i}", selected=True) for i in range(30)]
|
|
|
|
|
|
constraint_verifier.verify_that(must_select_target_count).given(
|
|
|
*stocks, config
|
|
|
).penalizes(0)
|
|
|
|
|
|
|
|
|
class TestPenalizeUnselectedStock:
|
|
|
"""Tests for the penalize_unselected_stock soft constraint.
|
|
|
|
|
|
This is a parameterized constraint that reads unselected_penalty from PortfolioConfig.
|
|
|
Default penalty is 10000 per unselected stock.
|
|
|
It drives the solver to select stocks without affecting hard feasibility.
|
|
|
"""
|
|
|
|
|
|
def test_selected_stock_no_penalty(self) -> None:
|
|
|
"""A selected stock should not be penalized."""
|
|
|
stock = create_stock("STK1", selected=True)
|
|
|
|
|
|
constraint_verifier.verify_that(penalize_unselected_stock).given(
|
|
|
stock, DEFAULT_CONFIG
|
|
|
).penalizes(0)
|
|
|
|
|
|
def test_unselected_stock_penalized(self) -> None:
|
|
|
"""An unselected stock should be penalized by unselected_penalty (default 10000)."""
|
|
|
stock = create_stock("STK1", selected=False)
|
|
|
|
|
|
|
|
|
constraint_verifier.verify_that(penalize_unselected_stock).given(
|
|
|
stock, DEFAULT_CONFIG
|
|
|
).penalizes_by(10000)
|
|
|
|
|
|
def test_mixed_selection(self) -> None:
|
|
|
"""Mix of selected and unselected stocks - only unselected penalized."""
|
|
|
selected = [create_stock(f"SEL{i}", selected=True) for i in range(10)]
|
|
|
unselected = [create_stock(f"UNS{i}", selected=False) for i in range(5)]
|
|
|
|
|
|
|
|
|
constraint_verifier.verify_that(penalize_unselected_stock).given(
|
|
|
*selected, *unselected, DEFAULT_CONFIG
|
|
|
).penalizes_by(50000)
|
|
|
|
|
|
def test_custom_penalty(self) -> None:
|
|
|
"""Custom unselected_penalty=5000: 2 unselected should penalize by 10000."""
|
|
|
config = PortfolioConfig(target_count=20, max_per_sector=5, unselected_penalty=5000)
|
|
|
unselected = [create_stock(f"UNS{i}", selected=False) for i in range(2)]
|
|
|
|
|
|
|
|
|
constraint_verifier.verify_that(penalize_unselected_stock).given(
|
|
|
*unselected, config
|
|
|
).penalizes_by(10000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestSectorExposureLimit:
|
|
|
"""Tests for the sector_exposure_limit constraint.
|
|
|
|
|
|
This is a parameterized constraint that reads max_per_sector from PortfolioConfig.
|
|
|
Default is 5 stocks per sector (= 25% with 20 total stocks).
|
|
|
"""
|
|
|
|
|
|
def test_at_limit_no_penalty(self) -> None:
|
|
|
"""Having exactly max_per_sector stocks in each sector should not be penalized."""
|
|
|
|
|
|
tech = [create_stock(f"TECH{i}", sector="Technology", selected=True) for i in range(5)]
|
|
|
health = [create_stock(f"HLTH{i}", sector="Healthcare", selected=True) for i in range(5)]
|
|
|
finance = [create_stock(f"FIN{i}", sector="Finance", selected=True) for i in range(5)]
|
|
|
energy = [create_stock(f"NRG{i}", sector="Energy", selected=True) for i in range(5)]
|
|
|
|
|
|
constraint_verifier.verify_that(sector_exposure_limit).given(
|
|
|
*tech, *health, *finance, *energy, DEFAULT_CONFIG
|
|
|
).penalizes(0)
|
|
|
|
|
|
def test_one_over_limit_penalizes_1(self) -> None:
|
|
|
"""Having max_per_sector + 1 stocks in a sector should be penalized by 1."""
|
|
|
tech = [create_stock(f"TECH{i}", sector="Technology", selected=True) for i in range(6)]
|
|
|
|
|
|
constraint_verifier.verify_that(sector_exposure_limit).given(
|
|
|
*tech, DEFAULT_CONFIG
|
|
|
).penalizes_by(1)
|
|
|
|
|
|
def test_three_over_limit_penalizes_3(self) -> None:
|
|
|
"""Having max_per_sector + 3 stocks in a sector should be penalized by 3."""
|
|
|
tech = [create_stock(f"TECH{i}", sector="Technology", selected=True) for i in range(8)]
|
|
|
|
|
|
constraint_verifier.verify_that(sector_exposure_limit).given(
|
|
|
*tech, DEFAULT_CONFIG
|
|
|
).penalizes_by(3)
|
|
|
|
|
|
def test_multiple_sectors_over_limit(self) -> None:
|
|
|
"""Multiple sectors over limit should each contribute penalty."""
|
|
|
|
|
|
tech = [create_stock(f"TECH{i}", sector="Technology", selected=True) for i in range(6)]
|
|
|
health = [create_stock(f"HLTH{i}", sector="Healthcare", selected=True) for i in range(7)]
|
|
|
|
|
|
constraint_verifier.verify_that(sector_exposure_limit).given(
|
|
|
*tech, *health, DEFAULT_CONFIG
|
|
|
).penalizes_by(3)
|
|
|
|
|
|
def test_unselected_stocks_not_counted(self) -> None:
|
|
|
"""Unselected stocks should not count toward sector limits."""
|
|
|
|
|
|
selected = [create_stock(f"STECH{i}", sector="Technology", selected=True) for i in range(5)]
|
|
|
unselected = [create_stock(f"UTECH{i}", sector="Technology", selected=False) for i in range(5)]
|
|
|
|
|
|
constraint_verifier.verify_that(sector_exposure_limit).given(
|
|
|
*selected, *unselected, DEFAULT_CONFIG
|
|
|
).penalizes(0)
|
|
|
|
|
|
def test_single_sector_at_limit_no_penalty(self) -> None:
|
|
|
"""A single sector with exactly max_per_sector stocks should not be penalized."""
|
|
|
stocks = [create_stock(f"TECH{i}", sector="Technology", selected=True) for i in range(5)]
|
|
|
|
|
|
constraint_verifier.verify_that(sector_exposure_limit).given(
|
|
|
*stocks, DEFAULT_CONFIG
|
|
|
).penalizes(0)
|
|
|
|
|
|
def test_custom_max_per_sector_3(self) -> None:
|
|
|
"""Custom max_per_sector=3: 4 stocks should penalize by 1."""
|
|
|
config = PortfolioConfig(target_count=15, max_per_sector=3, unselected_penalty=10000)
|
|
|
tech = [create_stock(f"TECH{i}", sector="Technology", selected=True) for i in range(4)]
|
|
|
|
|
|
constraint_verifier.verify_that(sector_exposure_limit).given(
|
|
|
*tech, config
|
|
|
).penalizes_by(1)
|
|
|
|
|
|
def test_custom_max_per_sector_8(self) -> None:
|
|
|
"""Custom max_per_sector=8: 8 stocks should not penalize."""
|
|
|
config = PortfolioConfig(target_count=30, max_per_sector=8, unselected_penalty=10000)
|
|
|
tech = [create_stock(f"TECH{i}", sector="Technology", selected=True) for i in range(8)]
|
|
|
|
|
|
constraint_verifier.verify_that(sector_exposure_limit).given(
|
|
|
*tech, config
|
|
|
).penalizes(0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMaximizeExpectedReturn:
|
|
|
"""Tests for the maximize_expected_return constraint.
|
|
|
|
|
|
This constraint rewards selected stocks based on predicted_return * 10000.
|
|
|
It does not use PortfolioConfig (not parameterized).
|
|
|
"""
|
|
|
|
|
|
def test_high_return_stock_rewarded(self) -> None:
|
|
|
"""Stock with 12% predicted return should be rewarded 1200 points."""
|
|
|
|
|
|
stock = create_stock("AAPL", predicted_return=0.12, selected=True)
|
|
|
|
|
|
constraint_verifier.verify_that(maximize_expected_return).given(
|
|
|
stock
|
|
|
).rewards_with(1200)
|
|
|
|
|
|
def test_low_return_stock_rewarded_less(self) -> None:
|
|
|
"""Stock with 5% predicted return should be rewarded 500 points."""
|
|
|
|
|
|
stock = create_stock("INTC", predicted_return=0.05, selected=True)
|
|
|
|
|
|
constraint_verifier.verify_that(maximize_expected_return).given(
|
|
|
stock
|
|
|
).rewards_with(500)
|
|
|
|
|
|
def test_multiple_stocks_reward_sum(self) -> None:
|
|
|
"""Multiple selected stocks should have rewards summed."""
|
|
|
|
|
|
stock1 = create_stock("STK1", predicted_return=0.10, selected=True)
|
|
|
stock2 = create_stock("STK2", predicted_return=0.15, selected=True)
|
|
|
|
|
|
constraint_verifier.verify_that(maximize_expected_return).given(
|
|
|
stock1, stock2
|
|
|
).rewards_with(2500)
|
|
|
|
|
|
def test_unselected_stock_not_rewarded(self) -> None:
|
|
|
"""Unselected stocks should not contribute to reward."""
|
|
|
stock = create_stock("STK1", predicted_return=0.20, selected=False)
|
|
|
|
|
|
constraint_verifier.verify_that(maximize_expected_return).given(
|
|
|
stock
|
|
|
).rewards(0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestIntegration:
|
|
|
"""Integration tests for the complete constraint set."""
|
|
|
|
|
|
def test_valid_portfolio_no_sector_violations(self) -> None:
|
|
|
"""A valid portfolio should have 0 hard constraint violations."""
|
|
|
|
|
|
tech = [create_stock(f"TECH{i}", sector="Technology", predicted_return=0.15, selected=True) for i in range(5)]
|
|
|
health = [create_stock(f"HLTH{i}", sector="Healthcare", predicted_return=0.10, selected=True) for i in range(5)]
|
|
|
finance = [create_stock(f"FIN{i}", sector="Finance", predicted_return=0.08, selected=True) for i in range(5)]
|
|
|
energy = [create_stock(f"NRG{i}", sector="Energy", predicted_return=0.05, selected=True) for i in range(5)]
|
|
|
|
|
|
all_stocks = tech + health + finance + energy
|
|
|
|
|
|
|
|
|
constraint_verifier.verify_that(sector_exposure_limit).given(*all_stocks, DEFAULT_CONFIG).penalizes(0)
|
|
|
constraint_verifier.verify_that(must_select_target_count).given(*all_stocks, DEFAULT_CONFIG).penalizes(0)
|
|
|
|
|
|
def test_high_return_portfolio_preferred(self) -> None:
|
|
|
"""Higher return stocks should result in higher soft score."""
|
|
|
|
|
|
low_return = [create_stock(f"LOW{i}", predicted_return=0.10, selected=True) for i in range(5)]
|
|
|
|
|
|
|
|
|
high_return = [create_stock(f"HIGH{i}", predicted_return=0.15, selected=True) for i in range(5)]
|
|
|
|
|
|
|
|
|
low_reward = 5 * 1000
|
|
|
high_reward = 5 * 1500
|
|
|
|
|
|
constraint_verifier.verify_that(maximize_expected_return).given(*low_return).rewards_with(low_reward)
|
|
|
constraint_verifier.verify_that(maximize_expected_return).given(*high_return).rewards_with(high_reward)
|
|
|
|
|
|
assert high_reward > low_reward, "High return portfolio should have higher reward"
|
|
|
|
|
|
def test_custom_config_integration(self) -> None:
|
|
|
"""Test that custom config values are respected across constraints."""
|
|
|
|
|
|
config = PortfolioConfig(target_count=10, max_per_sector=3, unselected_penalty=5000)
|
|
|
|
|
|
|
|
|
tech = [create_stock(f"TECH{i}", sector="Technology", selected=True) for i in range(3)]
|
|
|
health = [create_stock(f"HLTH{i}", sector="Healthcare", selected=True) for i in range(3)]
|
|
|
finance = [create_stock(f"FIN{i}", sector="Finance", selected=True) for i in range(3)]
|
|
|
energy = [create_stock(f"NRG{i}", sector="Energy", selected=True) for i in range(1)]
|
|
|
unselected = [create_stock(f"UNS{i}", sector="Other", selected=False) for i in range(2)]
|
|
|
|
|
|
all_stocks = tech + health + finance + energy + unselected
|
|
|
|
|
|
|
|
|
constraint_verifier.verify_that(must_select_target_count).given(*all_stocks, config).penalizes(0)
|
|
|
|
|
|
|
|
|
constraint_verifier.verify_that(sector_exposure_limit).given(*all_stocks, config).penalizes(0)
|
|
|
|
|
|
|
|
|
constraint_verifier.verify_that(penalize_unselected_stock).given(*all_stocks, config).penalizes_by(10000)
|
|
|
|