blackopsrepl/portfolio-optimization-python
1
1"""
2Portfolio Optimization Constraints
3
4This module defines the business rules for portfolio construction:
5
6HARD CONSTRAINTS (must be satisfied):
71. must_select_target_count: Pick exactly N stocks (configurable, default 20)
82. sector_exposure_limit: No sector can exceed X stocks (configurable, default 5)
9
10SOFT CONSTRAINTS (optimize for):
113. penalize_unselected_stock: Drive solver to select stocks (high penalty)
124. maximize_expected_return: Prefer stocks with higher ML-predicted returns
13
14WHY CONSTRAINT SOLVING BEATS IF/ELSE:
15- With 50 stocks and 5 sectors, there are millions of possible portfolios
16- Multiple constraints interact: selecting high-return stocks might violate sector limits
17- Greedy algorithms get stuck in local optima
18- Constraint solvers explore the solution space systematically
19
20CONFIGURATION:
21- Constraints read thresholds from PortfolioConfig (a problem fact)
22- target_count: Number of stocks to select
23- max_per_sector: Maximum stocks allowed in any single sector
24- unselected_penalty: Soft penalty per unselected stock (drives selection)
25
26FINANCE CONCEPTS:
27- Sector diversification: Don't put all eggs in one basket
28- Expected return: ML model's prediction of future stock performance
29- Equal weight: Each selected stock gets the same percentage (5% for 20 stocks)
30"""
31from typing import Any
32
33from solverforge_legacy.solver.score import (
34 constraint_provider,
35 ConstraintFactory,
36 HardSoftScore,
37 ConstraintCollectors,
38 Constraint,
39)
40
41from .domain import StockSelection, PortfolioConfig
42
43
44@constraint_provider
45def define_constraints(constraint_factory: ConstraintFactory) -> list[Constraint]:
46 """
47 Define all portfolio optimization constraints.
48
49 Returns a list of constraint functions that the solver will enforce.
50 Hard constraints must be satisfied; soft constraints are optimized.
51
52 IMPLEMENTATION NOTE:
53 The stock count is enforced via:
54 1. must_select_exactly_20_stocks - hard constraint, penalizes if MORE than 20 selected
55 2. penalize_unselected_stock - soft constraint with high penalty, drives solver to select stocks
56
57 We don't use a hard "minimum 20" constraint because group_by(count()) on an
58 empty stream returns nothing (not 0). Instead, we rely on the large soft penalty
59 for unselected stocks to push the solver toward selecting exactly 20.
60 """
61 return [
62 # Hard constraints (must be satisfied)
63 must_select_target_count(constraint_factory), # Max target_count selected
64 sector_exposure_limit(constraint_factory), # Max per sector
65
66 # Soft constraints (maximize/minimize)
67 penalize_unselected_stock(constraint_factory), # Drives selection toward target
68 maximize_expected_return(constraint_factory), # Optimize returns
69
70 # ============================================================
71 # TUTORIAL: Uncomment the constraint below to add sector preference
72 # ============================================================
73 # preferred_sector_bonus(constraint_factory),
74 ]
75
76
77def must_select_target_count(constraint_factory: ConstraintFactory) -> Constraint:
78 """
79 Hard constraint: Must not select MORE than target_count stocks.
80
81 Business rule: "Pick at most N stocks for the portfolio"
82 (N is configurable via PortfolioConfig.target_count, default 20)
83
84 This constraint only fires when count > target_count. Combined with
85 penalize_unselected_stock, ensures the target count is reached.
86
87 Note: We use the 'selected' property which returns True/False based on selection.value
88 """
89 return (
90 constraint_factory.for_each(StockSelection)
91 .filter(lambda stock: stock.selected is True)
92 .group_by(ConstraintCollectors.count())
93 .join(PortfolioConfig)
94 .filter(lambda count, config: count > config.target_count)
95 .penalize(
96 HardSoftScore.ONE_HARD,
97 lambda count, config: count - config.target_count # Penalty = stocks over target
98 )
99 .as_constraint("Must select target count")
100 )
101
102
103def penalize_unselected_stock(constraint_factory: ConstraintFactory) -> Constraint:
104 """
105 Soft constraint: Penalize each unselected stock.
106
107 This constraint drives the solver to select stocks. Without it,
108 the solver might leave all stocks unselected (0 hard score from
109 other constraints due to empty stream issue).
110
111 We use a LARGE soft penalty (configurable, default 10000) to ensure
112 the solver prioritizes selecting stocks before optimizing returns.
113 This is higher than the max return reward (~2000 per stock).
114
115 With 25 stocks and 20 needed, the optimal has 5 unselected = -50000 soft.
116 """
117 return (
118 constraint_factory.for_each(StockSelection)
119 .filter(lambda stock: stock.selected is False)
120 .join(PortfolioConfig)
121 .penalize(
122 HardSoftScore.ONE_SOFT,
123 lambda stock, config: config.unselected_penalty
124 )
125 .as_constraint("Penalize unselected stock")
126 )
127
128
129def sector_exposure_limit(constraint_factory: ConstraintFactory) -> Constraint:
130 """
131 Hard constraint: No sector can exceed max_per_sector stocks.
132
133 Business rule: "Maximum N stocks from any single sector"
134 (N is configurable via PortfolioConfig.max_per_sector, default 5)
135
136 Why this matters (DIVERSIFICATION):
137 - If Tech sector crashes 50%, you only lose X% * 50% of portfolio
138 - Without this limit, you might pick all Tech stocks (they have highest returns!)
139 - Diversification protects against sector-specific risks
140
141 Example with default (5 stocks max = 25%):
142 - Technology: 6 stocks selected = 30% exposure
143 - Sector limit: 25% (5 stocks max)
144 - Penalty: 6 - 5 = 1 (one stock over limit)
145 """
146 return (
147 constraint_factory.for_each(StockSelection)
148 .filter(lambda stock: stock.selected is True)
149 .group_by(
150 lambda stock: stock.sector, # Group by sector name
151 ConstraintCollectors.count() # Count stocks per sector
152 )
153 .join(PortfolioConfig)
154 .filter(lambda sector, count, config: count > config.max_per_sector)
155 .penalize(
156 HardSoftScore.ONE_HARD,
157 lambda sector, count, config: count - config.max_per_sector
158 )
159 .as_constraint("Max stocks per sector")
160 )
161
162
163def maximize_expected_return(constraint_factory: ConstraintFactory) -> Constraint:
164 """
165 Soft constraint: Maximize total expected portfolio return.
166
167 Business rule: "Among all valid portfolios, pick stocks with highest predicted returns"
168
169 Why this is a SOFT constraint:
170 - It's our optimization objective, not a hard rule
171 - We WANT high returns, but we MUST respect sector limits
172 - The solver balances this against hard constraints
173
174 Math:
175 - Portfolio return = sum of (weight * predicted_return) for each stock
176 - With 20 stocks at 5% each: return = sum of (0.05 * predicted_return)
177 - We reward based on predicted_return to prefer high-return stocks
178
179 Example:
180 - Apple: predicted_return = 0.12 (12%)
181 - Weight: 5% = 0.05
182 - Contribution to score: 0.05 * 0.12 * 10000 = 60 points
183
184 Note: We multiply by 10000 to convert decimals to integer scores
185 """
186 return (
187 constraint_factory.for_each(StockSelection)
188 .filter(lambda stock: stock.selected is True)
189 .reward(
190 HardSoftScore.ONE_SOFT,
191 # Reward = predicted return (scaled to integer)
192 # Higher predicted return = higher reward
193 lambda stock: int(stock.predicted_return * 10000)
194 )
195 .as_constraint("Maximize expected return")
196 )
197
198
199# ============================================================
200# TUTORIAL CONSTRAINT: Preferred Sector Bonus
201# ============================================================
202# Uncomment this constraint to give a small bonus to preferred sectors.
203# This demonstrates how to add custom business logic to the optimization.
204#
205# Scenario: Your investment committee wants to slightly favor Technology
206# and Healthcare sectors because they expect these sectors to outperform.
207#
208# def preferred_sector_bonus(constraint_factory: ConstraintFactory):
209# """
210# Soft constraint: Give a small bonus to stocks from preferred sectors.
211#
212# This is a TUTORIAL constraint - uncomment to see how it affects
213# the portfolio composition.
214#
215# Business rule: "Slightly prefer Technology and Healthcare stocks"
216#
217# Note: This is intentionally a SMALL bonus so it doesn't override
218# the expected return constraint. It just acts as a tiebreaker.
219# """
220# PREFERRED_SECTORS = {"Technology", "Healthcare"}
221# BONUS_POINTS = 50 # Small bonus per preferred stock
222#
223# return (
224# constraint_factory.for_each(StockSelection)
225# .filter(lambda stock: stock.selected is True)
226# .filter(lambda stock: stock.sector in PREFERRED_SECTORS)
227# .reward(
228# HardSoftScore.ONE_SOFT,
229# lambda stock: BONUS_POINTS
230# )
231# .as_constraint("Preferred sector bonus")
232# )
233 