| """
|
| Feasibility tests for the Portfolio Optimization quickstart.
|
|
|
| These tests verify that the solver can find valid solutions
|
| for the demo datasets.
|
| """
|
| from solverforge_legacy.solver import SolverFactory
|
| from solverforge_legacy.solver.config import (
|
| SolverConfig,
|
| ScoreDirectorFactoryConfig,
|
| TerminationConfig,
|
| Duration,
|
| )
|
|
|
| from portfolio_optimization.domain import PortfolioOptimizationPlan, StockSelection
|
| from portfolio_optimization.constraints import define_constraints
|
| from portfolio_optimization.demo_data import generate_demo_data, DemoData
|
|
|
| import pytest
|
|
|
|
|
| def solve_portfolio(plan: PortfolioOptimizationPlan, seconds: int = 5) -> PortfolioOptimizationPlan:
|
| """Run the solver on a portfolio for a given number of seconds."""
|
| solver_config = SolverConfig(
|
| solution_class=PortfolioOptimizationPlan,
|
| entity_class_list=[StockSelection],
|
| score_director_factory_config=ScoreDirectorFactoryConfig(
|
| constraint_provider_function=define_constraints
|
| ),
|
| termination_config=TerminationConfig(spent_limit=Duration(seconds=seconds)),
|
| )
|
|
|
| solver = SolverFactory.create(solver_config).build_solver()
|
| return solver.solve(plan)
|
|
|
|
|
| class TestFeasibility:
|
| """Test that the solver can find feasible solutions."""
|
|
|
| def test_small_dataset_feasible(self):
|
| """The SMALL dataset should be solvable to a feasible solution."""
|
| plan = generate_demo_data(DemoData.SMALL)
|
|
|
| solution = solve_portfolio(plan, seconds=10)
|
|
|
|
|
| assert solution is not None
|
| assert solution.score is not None
|
|
|
|
|
| assert solution.score.hard_score == 0, \
|
| f"Solution should be feasible, got hard score: {solution.score.hard_score}"
|
|
|
|
|
| selected_count = solution.get_selected_count()
|
| assert selected_count == 20, \
|
| f"Should select 20 stocks, got {selected_count}"
|
|
|
| def test_large_dataset_feasible(self):
|
| """The LARGE dataset should be solvable to a feasible solution."""
|
| plan = generate_demo_data(DemoData.LARGE)
|
|
|
| solution = solve_portfolio(plan, seconds=15)
|
|
|
|
|
| assert solution is not None
|
| assert solution.score is not None
|
|
|
|
|
| assert solution.score.hard_score == 0, \
|
| f"Solution should be feasible, got hard score: {solution.score.hard_score}"
|
|
|
|
|
| selected_count = solution.get_selected_count()
|
| assert selected_count == 20, \
|
| f"Should select 20 stocks, got {selected_count}"
|
|
|
| def test_sector_limits_respected(self):
|
| """The solver should respect sector exposure limits."""
|
| plan = generate_demo_data(DemoData.SMALL)
|
|
|
| solution = solve_portfolio(plan, seconds=10)
|
|
|
|
|
| sector_weights = solution.get_sector_weights()
|
|
|
| for sector, weight in sector_weights.items():
|
| assert weight <= 0.26, \
|
| f"Sector {sector} has {weight*100:.1f}% weight, exceeds 25% limit"
|
|
|
| def test_positive_expected_return(self):
|
| """The solver should find a portfolio with positive expected return."""
|
| plan = generate_demo_data(DemoData.SMALL)
|
|
|
| solution = solve_portfolio(plan, seconds=10)
|
|
|
| expected_return = solution.get_expected_return()
|
|
|
|
|
| assert expected_return > 0.05, \
|
| f"Expected return should be > 5%, got {expected_return*100:.2f}%"
|
|
|
| def test_expected_return_reasonable(self):
|
| """The expected return should be reasonable for valid solutions."""
|
| plan = generate_demo_data(DemoData.SMALL)
|
|
|
| solution = solve_portfolio(plan, seconds=10)
|
|
|
|
|
| expected_return = solution.get_expected_return()
|
| assert expected_return > 0, \
|
| f"Expected return should be positive, got {expected_return}"
|
|
|
|
|
| class TestDemoData:
|
| """Test demo data generation."""
|
|
|
| def test_small_dataset_has_25_stocks(self):
|
| """SMALL dataset should have 25 stocks (5+ per sector for feasibility)."""
|
| plan = generate_demo_data(DemoData.SMALL)
|
|
|
| assert len(plan.stocks) == 25
|
|
|
| def test_large_dataset_has_51_stocks(self):
|
| """LARGE dataset should have 51 stocks."""
|
| plan = generate_demo_data(DemoData.LARGE)
|
|
|
| assert len(plan.stocks) == 51
|
|
|
| def test_stocks_have_sectors(self):
|
| """All stocks should have a sector assigned."""
|
| plan = generate_demo_data(DemoData.SMALL)
|
|
|
| for stock in plan.stocks:
|
| assert stock.sector is not None
|
| assert len(stock.sector) > 0
|
|
|
| def test_stocks_have_predictions(self):
|
| """All stocks should have predicted returns."""
|
| plan = generate_demo_data(DemoData.SMALL)
|
|
|
| for stock in plan.stocks:
|
| assert stock.predicted_return is not None
|
|
|
| assert -0.10 <= stock.predicted_return <= 0.25
|
|
|
| def test_stocks_initially_unselected(self):
|
| """All stocks should start with selected=None."""
|
| plan = generate_demo_data(DemoData.SMALL)
|
|
|
| for stock in plan.stocks:
|
| assert stock.selected is None
|
|
|
| def test_has_multiple_sectors(self):
|
| """Demo data should have multiple sectors for diversification testing."""
|
| plan = generate_demo_data(DemoData.SMALL)
|
|
|
| sectors = {stock.sector for stock in plan.stocks}
|
|
|
| assert len(sectors) >= 4, \
|
| f"Should have at least 4 sectors for diversification, got {len(sectors)}"
|
|
|