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