|
|
"""
|
|
|
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)}"
|
|
|
|