Team Ai
Apppublic

blackopsrepl/portfolio-optimization-python

sourceHugging Faceapache-2.0updated 10mo agoView on Hugging Face
1likes
constraints.py233 linesDownload Raw Back to portfolio_optimization
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