| """
|
| Portfolio Optimization Constraints
|
|
|
| This module defines the business rules for portfolio construction:
|
|
|
| HARD CONSTRAINTS (must be satisfied):
|
| 1. must_select_target_count: Pick exactly N stocks (configurable, default 20)
|
| 2. sector_exposure_limit: No sector can exceed X stocks (configurable, default 5)
|
|
|
| SOFT CONSTRAINTS (optimize for):
|
| 3. penalize_unselected_stock: Drive solver to select stocks (high penalty)
|
| 4. maximize_expected_return: Prefer stocks with higher ML-predicted returns
|
|
|
| WHY CONSTRAINT SOLVING BEATS IF/ELSE:
|
| - With 50 stocks and 5 sectors, there are millions of possible portfolios
|
| - Multiple constraints interact: selecting high-return stocks might violate sector limits
|
| - Greedy algorithms get stuck in local optima
|
| - Constraint solvers explore the solution space systematically
|
|
|
| CONFIGURATION:
|
| - Constraints read thresholds from PortfolioConfig (a problem fact)
|
| - target_count: Number of stocks to select
|
| - max_per_sector: Maximum stocks allowed in any single sector
|
| - unselected_penalty: Soft penalty per unselected stock (drives selection)
|
|
|
| FINANCE CONCEPTS:
|
| - Sector diversification: Don't put all eggs in one basket
|
| - Expected return: ML model's prediction of future stock performance
|
| - Equal weight: Each selected stock gets the same percentage (5% for 20 stocks)
|
| """
|
| from typing import Any
|
|
|
| from solverforge_legacy.solver.score import (
|
| constraint_provider,
|
| ConstraintFactory,
|
| HardSoftScore,
|
| ConstraintCollectors,
|
| Constraint,
|
| )
|
|
|
| from .domain import StockSelection, PortfolioConfig
|
|
|
|
|
| @constraint_provider
|
| def define_constraints(constraint_factory: ConstraintFactory) -> list[Constraint]:
|
| """
|
| Define all portfolio optimization constraints.
|
|
|
| Returns a list of constraint functions that the solver will enforce.
|
| Hard constraints must be satisfied; soft constraints are optimized.
|
|
|
| IMPLEMENTATION NOTE:
|
| The stock count is enforced via:
|
| 1. must_select_exactly_20_stocks - hard constraint, penalizes if MORE than 20 selected
|
| 2. penalize_unselected_stock - soft constraint with high penalty, drives solver to select stocks
|
|
|
| We don't use a hard "minimum 20" constraint because group_by(count()) on an
|
| empty stream returns nothing (not 0). Instead, we rely on the large soft penalty
|
| for unselected stocks to push the solver toward selecting exactly 20.
|
| """
|
| return [
|
|
|
| must_select_target_count(constraint_factory),
|
| sector_exposure_limit(constraint_factory),
|
|
|
|
|
| penalize_unselected_stock(constraint_factory),
|
| maximize_expected_return(constraint_factory),
|
|
|
|
|
|
|
|
|
|
|
| ]
|
|
|
|
|
| def must_select_target_count(constraint_factory: ConstraintFactory) -> Constraint:
|
| """
|
| Hard constraint: Must not select MORE than target_count stocks.
|
|
|
| Business rule: "Pick at most N stocks for the portfolio"
|
| (N is configurable via PortfolioConfig.target_count, default 20)
|
|
|
| This constraint only fires when count > target_count. Combined with
|
| penalize_unselected_stock, ensures the target count is reached.
|
|
|
| Note: We use the 'selected' property which returns True/False based on selection.value
|
| """
|
| return (
|
| constraint_factory.for_each(StockSelection)
|
| .filter(lambda stock: stock.selected is True)
|
| .group_by(ConstraintCollectors.count())
|
| .join(PortfolioConfig)
|
| .filter(lambda count, config: count > config.target_count)
|
| .penalize(
|
| HardSoftScore.ONE_HARD,
|
| lambda count, config: count - config.target_count
|
| )
|
| .as_constraint("Must select target count")
|
| )
|
|
|
|
|
| def penalize_unselected_stock(constraint_factory: ConstraintFactory) -> Constraint:
|
| """
|
| Soft constraint: Penalize each unselected stock.
|
|
|
| This constraint drives the solver to select stocks. Without it,
|
| the solver might leave all stocks unselected (0 hard score from
|
| other constraints due to empty stream issue).
|
|
|
| We use a LARGE soft penalty (configurable, default 10000) to ensure
|
| the solver prioritizes selecting stocks before optimizing returns.
|
| This is higher than the max return reward (~2000 per stock).
|
|
|
| With 25 stocks and 20 needed, the optimal has 5 unselected = -50000 soft.
|
| """
|
| return (
|
| constraint_factory.for_each(StockSelection)
|
| .filter(lambda stock: stock.selected is False)
|
| .join(PortfolioConfig)
|
| .penalize(
|
| HardSoftScore.ONE_SOFT,
|
| lambda stock, config: config.unselected_penalty
|
| )
|
| .as_constraint("Penalize unselected stock")
|
| )
|
|
|
|
|
| def sector_exposure_limit(constraint_factory: ConstraintFactory) -> Constraint:
|
| """
|
| Hard constraint: No sector can exceed max_per_sector stocks.
|
|
|
| Business rule: "Maximum N stocks from any single sector"
|
| (N is configurable via PortfolioConfig.max_per_sector, default 5)
|
|
|
| Why this matters (DIVERSIFICATION):
|
| - If Tech sector crashes 50%, you only lose X% * 50% of portfolio
|
| - Without this limit, you might pick all Tech stocks (they have highest returns!)
|
| - Diversification protects against sector-specific risks
|
|
|
| Example with default (5 stocks max = 25%):
|
| - Technology: 6 stocks selected = 30% exposure
|
| - Sector limit: 25% (5 stocks max)
|
| - Penalty: 6 - 5 = 1 (one stock over limit)
|
| """
|
| return (
|
| constraint_factory.for_each(StockSelection)
|
| .filter(lambda stock: stock.selected is True)
|
| .group_by(
|
| lambda stock: stock.sector,
|
| ConstraintCollectors.count()
|
| )
|
| .join(PortfolioConfig)
|
| .filter(lambda sector, count, config: count > config.max_per_sector)
|
| .penalize(
|
| HardSoftScore.ONE_HARD,
|
| lambda sector, count, config: count - config.max_per_sector
|
| )
|
| .as_constraint("Max stocks per sector")
|
| )
|
|
|
|
|
| def maximize_expected_return(constraint_factory: ConstraintFactory) -> Constraint:
|
| """
|
| Soft constraint: Maximize total expected portfolio return.
|
|
|
| Business rule: "Among all valid portfolios, pick stocks with highest predicted returns"
|
|
|
| Why this is a SOFT constraint:
|
| - It's our optimization objective, not a hard rule
|
| - We WANT high returns, but we MUST respect sector limits
|
| - The solver balances this against hard constraints
|
|
|
| Math:
|
| - Portfolio return = sum of (weight * predicted_return) for each stock
|
| - With 20 stocks at 5% each: return = sum of (0.05 * predicted_return)
|
| - We reward based on predicted_return to prefer high-return stocks
|
|
|
| Example:
|
| - Apple: predicted_return = 0.12 (12%)
|
| - Weight: 5% = 0.05
|
| - Contribution to score: 0.05 * 0.12 * 10000 = 60 points
|
|
|
| Note: We multiply by 10000 to convert decimals to integer scores
|
| """
|
| return (
|
| constraint_factory.for_each(StockSelection)
|
| .filter(lambda stock: stock.selected is True)
|
| .reward(
|
| HardSoftScore.ONE_SOFT,
|
|
|
|
|
| lambda stock: int(stock.predicted_return * 10000)
|
| )
|
| .as_constraint("Maximize expected return")
|
| )
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|