blackopsrepl commited on
Commit
d9f5c15
·
0 Parent(s):
.gitignore ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ target
2
+ local
3
+ __pycache__
4
+ .pytest_cache
5
+ /*.egg-info
6
+ /**/dist
7
+ /**/*.egg-info
8
+ /**/*-stubs
9
+ .venv
10
+
11
+ # Eclipse, Netbeans and IntelliJ files
12
+ /.*
13
+ !/.github
14
+ !/.ci
15
+ !.gitignore
16
+ !.gitattributes
17
+ !/.mvn
18
+ /nbproject
19
+ *.ipr
20
+ *.iws
21
+ *.iml
22
+
23
+ # Repository wide ignore mac DS_Store files
24
+ .DS_Store
25
+ *.code-workspace
26
+ CLAUDE.md
27
+ DOCUMENTATION_AUDIT.md
28
+
29
+ **/.venv/**
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use Python 3.12 base image
2
+ FROM python:3.12
3
+
4
+ # Install JDK 21 (required for solverforge-legacy)
5
+ RUN apt-get update && \
6
+ apt-get install -y wget gnupg2 && \
7
+ wget -O- https://packages.adoptium.net/artifactory/api/gpg/key/public | gpg --dearmor > /usr/share/keyrings/adoptium-archive-keyring.gpg && \
8
+ echo "deb [signed-by=/usr/share/keyrings/adoptium-archive-keyring.gpg] https://packages.adoptium.net/artifactory/deb bookworm main" > /etc/apt/sources.list.d/adoptium.list && \
9
+ apt-get update && \
10
+ apt-get install -y temurin-21-jdk && \
11
+ apt-get clean && \
12
+ rm -rf /var/lib/apt/lists/*
13
+
14
+ # Copy application files
15
+ COPY . .
16
+
17
+ # Install the application
18
+ RUN pip install --no-cache-dir -e .
19
+
20
+ # Expose port 8080
21
+ EXPOSE 8080
22
+
23
+ # Run the application
24
+ CMD ["run-app"]
README.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Portfolio Optimization (Python)
3
+ emoji: 📈
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 8080
8
+ pinned: false
9
+ license: apache-2.0
10
+ short_description: SolverForge Portfolio Optimization Quickstart
11
+ ---
logging.conf ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [loggers]
2
+ keys=root,solverforge_solver
3
+
4
+ [handlers]
5
+ keys=consoleHandler
6
+
7
+ [formatters]
8
+ keys=simpleFormatter
9
+
10
+ [logger_root]
11
+ level=INFO
12
+ handlers=consoleHandler
13
+
14
+ [logger_solverforge_solver]
15
+ level=INFO
16
+ qualname=solverforge.solver
17
+ handlers=consoleHandler
18
+ propagate=0
19
+
20
+ [handler_consoleHandler]
21
+ class=StreamHandler
22
+ level=INFO
23
+ formatter=simpleFormatter
24
+ args=(sys.stdout,)
25
+
26
+ [formatter_simpleFormatter]
27
+ class=uvicorn.logging.ColourizedFormatter
28
+ format={levelprefix:<8} @ {name} : {message}
29
+ style={
30
+ use_colors=True
pyproject.toml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+
6
+ [project]
7
+ name = "portfolio_optimization"
8
+ version = "1.0.0"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ 'solverforge-legacy == 1.24.1',
12
+ 'fastapi == 0.111.0',
13
+ 'pydantic == 2.7.3',
14
+ 'uvicorn == 0.30.1',
15
+ 'pytest == 8.2.2',
16
+ ]
17
+
18
+
19
+ [project.scripts]
20
+ run-app = "portfolio_optimization:main"
src/portfolio_optimization/__init__.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Portfolio Optimization Quickstart
3
+
4
+ A SolverForge quickstart demonstrating constraint-based portfolio optimization.
5
+ Combines ML predictions with constraint solving to select an optimal stock portfolio.
6
+ """
7
+ import uvicorn
8
+
9
+ from .rest_api import app as app
10
+
11
+
12
+ def main():
13
+ """Run the portfolio optimization REST API server."""
14
+ config = uvicorn.Config(
15
+ "portfolio_optimization:app",
16
+ host="0.0.0.0",
17
+ port=8080,
18
+ log_config="logging.conf",
19
+ use_colors=True,
20
+ )
21
+ server = uvicorn.Server(config)
22
+ server.run()
23
+
24
+
25
+ if __name__ == "__main__":
26
+ main()
src/portfolio_optimization/constraints.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Portfolio Optimization Constraints
3
+
4
+ This module defines the business rules for portfolio construction:
5
+
6
+ HARD CONSTRAINTS (must be satisfied):
7
+ 1. must_select_target_count: Pick exactly N stocks (configurable, default 20)
8
+ 2. sector_exposure_limit: No sector can exceed X stocks (configurable, default 5)
9
+
10
+ SOFT CONSTRAINTS (optimize for):
11
+ 3. penalize_unselected_stock: Drive solver to select stocks (high penalty)
12
+ 4. maximize_expected_return: Prefer stocks with higher ML-predicted returns
13
+
14
+ WHY 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
+
20
+ CONFIGURATION:
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
+
26
+ FINANCE 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
+ """
31
+ from typing import Any
32
+
33
+ from solverforge_legacy.solver.score import (
34
+ constraint_provider,
35
+ ConstraintFactory,
36
+ HardSoftScore,
37
+ ConstraintCollectors,
38
+ Constraint,
39
+ )
40
+
41
+ from .domain import StockSelection, PortfolioConfig
42
+
43
+
44
+ @constraint_provider
45
+ def 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
+
77
+ def 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
+
103
+ def 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
+
129
+ def 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
+
163
+ def 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
+ # )
src/portfolio_optimization/converters.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Converters between domain objects and REST API models.
3
+
4
+ These functions handle the transformation between:
5
+ - Domain objects (dataclasses used by the solver)
6
+ - REST models (Pydantic models used by the API)
7
+ """
8
+ from . import domain
9
+ from .domain import SELECTED, NOT_SELECTED, PortfolioConfig
10
+
11
+
12
+ def stock_to_model(stock: domain.StockSelection) -> domain.StockSelectionModel:
13
+ """Convert a StockSelection domain object to REST model."""
14
+ # Note: Pydantic model has populate_by_name=True, allowing snake_case field names
15
+ return domain.StockSelectionModel( # type: ignore[call-arg]
16
+ stock_id=stock.stock_id,
17
+ stock_name=stock.stock_name,
18
+ sector=stock.sector,
19
+ predicted_return=stock.predicted_return,
20
+ selected=stock.selected, # Uses the @property that returns bool
21
+ )
22
+
23
+
24
+ def plan_to_metrics(plan: domain.PortfolioOptimizationPlan) -> domain.PortfolioMetricsModel | None:
25
+ """Calculate business metrics from a plan."""
26
+ if plan.get_selected_count() == 0:
27
+ return None
28
+
29
+ return domain.PortfolioMetricsModel( # type: ignore[call-arg]
30
+ expected_return=plan.get_expected_return(),
31
+ sector_count=plan.get_sector_count(),
32
+ max_sector_exposure=plan.get_max_sector_exposure(),
33
+ herfindahl_index=plan.get_herfindahl_index(),
34
+ diversification_score=plan.get_diversification_score(),
35
+ return_volatility=plan.get_return_volatility(),
36
+ sharpe_proxy=plan.get_sharpe_proxy(),
37
+ )
38
+
39
+
40
+ def plan_to_model(plan: domain.PortfolioOptimizationPlan) -> domain.PortfolioOptimizationPlanModel:
41
+ """Convert a PortfolioOptimizationPlan domain object to REST model."""
42
+ # Note: Pydantic model has populate_by_name=True, allowing snake_case field names
43
+ return domain.PortfolioOptimizationPlanModel( # type: ignore[call-arg]
44
+ stocks=[stock_to_model(s) for s in plan.stocks],
45
+ target_position_count=plan.target_position_count,
46
+ max_sector_percentage=plan.max_sector_percentage,
47
+ score=str(plan.score) if plan.score else None,
48
+ solver_status=plan.solver_status.name if plan.solver_status else None,
49
+ metrics=plan_to_metrics(plan),
50
+ )
51
+
52
+
53
+ def model_to_stock(model: domain.StockSelectionModel) -> domain.StockSelection:
54
+ """Convert a StockSelectionModel REST model to domain object.
55
+
56
+ Note: The REST model uses `selected: bool` but the domain uses
57
+ `selection: SelectionValue`. We convert here.
58
+ """
59
+ # Convert bool to SelectionValue (or None if not set)
60
+ selection = None
61
+ if model.selected is True:
62
+ selection = SELECTED
63
+ elif model.selected is False:
64
+ selection = NOT_SELECTED
65
+ # If model.selected is None, leave selection as None
66
+
67
+ return domain.StockSelection(
68
+ stock_id=model.stock_id,
69
+ stock_name=model.stock_name,
70
+ sector=model.sector,
71
+ predicted_return=model.predicted_return,
72
+ selection=selection,
73
+ )
74
+
75
+
76
+ def model_to_plan(model: domain.PortfolioOptimizationPlanModel) -> domain.PortfolioOptimizationPlan:
77
+ """Convert a PortfolioOptimizationPlanModel REST model to domain object.
78
+
79
+ Creates a PortfolioConfig from the model's target_position_count and
80
+ max_sector_percentage so that constraints can access these values.
81
+ """
82
+ stocks = [model_to_stock(s) for s in model.stocks]
83
+
84
+ # Parse score if provided
85
+ score = None
86
+ if model.score:
87
+ from solverforge_legacy.solver.score import HardSoftScore
88
+ score = HardSoftScore.parse(model.score)
89
+
90
+ # Parse solver status if provided
91
+ solver_status = domain.SolverStatus.NOT_SOLVING
92
+ if model.solver_status:
93
+ solver_status = domain.SolverStatus[model.solver_status]
94
+
95
+ # Calculate max_per_sector from max_sector_percentage and target_position_count
96
+ # Example: 25% of 20 stocks = 5 stocks max per sector
97
+ target_count = model.target_position_count
98
+ max_per_sector = max(1, int(model.max_sector_percentage * target_count))
99
+
100
+ # Create PortfolioConfig for constraints to access
101
+ portfolio_config = PortfolioConfig(
102
+ target_count=target_count,
103
+ max_per_sector=max_per_sector,
104
+ unselected_penalty=10000, # Default penalty
105
+ )
106
+
107
+ return domain.PortfolioOptimizationPlan(
108
+ stocks=stocks,
109
+ target_position_count=model.target_position_count,
110
+ max_sector_percentage=model.max_sector_percentage,
111
+ portfolio_config=portfolio_config,
112
+ score=score,
113
+ solver_status=solver_status,
114
+ )
src/portfolio_optimization/demo_data.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Demo Data for Portfolio Optimization
3
+
4
+ This module provides sample stock data for the portfolio optimization quickstart.
5
+ The data includes 20 stocks across 4 sectors with ML-predicted returns.
6
+
7
+ In a real application, these predictions would come from an ML model trained
8
+ on historical stock data. For this quickstart, we use hardcoded realistic values.
9
+
10
+ FINANCE CONCEPTS:
11
+ - predicted_return: Expected percentage gain (0.12 = 12% expected return)
12
+ - sector: Industry classification for diversification
13
+ - Equal weight: Each selected stock gets 100%/20 = 5% of the portfolio
14
+ """
15
+ from enum import Enum
16
+ from dataclasses import dataclass
17
+
18
+ from .domain import StockSelection, PortfolioOptimizationPlan, PortfolioConfig
19
+
20
+
21
+ class DemoData(Enum):
22
+ """Available demo datasets."""
23
+ SMALL = 'SMALL' # 20 stocks - good for learning
24
+ LARGE = 'LARGE' # 50 stocks - more realistic
25
+
26
+
27
+ @dataclass
28
+ class DemoDataConfig:
29
+ """Configuration for demo data generation."""
30
+ target_position_count: int
31
+ max_sector_percentage: float
32
+
33
+
34
+ demo_data_configs = {
35
+ DemoData.SMALL: DemoDataConfig(
36
+ target_position_count=20,
37
+ max_sector_percentage=0.25,
38
+ ),
39
+ DemoData.LARGE: DemoDataConfig(
40
+ target_position_count=20,
41
+ max_sector_percentage=0.25,
42
+ ),
43
+ }
44
+
45
+
46
+ # Stock data with realistic ML predictions
47
+ # Format: (ticker, name, sector, predicted_return)
48
+ #
49
+ # SMALL dataset: 25 stocks, need to select 20
50
+ # This is FEASIBLE because we have 5+ stocks in each of 4 sectors (5*4=20 max from limits)
51
+ # Plus we have extra stocks to choose from in each sector
52
+ SMALL_DATASET_STOCKS = [
53
+ # TECHNOLOGY (7 stocks) - typically higher predicted returns
54
+ # Solver can pick max 5, so must choose best 5 from 7
55
+ ("AAPL", "Apple Inc.", "Technology", 0.12),
56
+ ("GOOGL", "Alphabet (Google)", "Technology", 0.15),
57
+ ("MSFT", "Microsoft Corp.", "Technology", 0.10),
58
+ ("NVDA", "NVIDIA Corp.", "Technology", 0.18),
59
+ ("META", "Meta Platforms", "Technology", 0.08),
60
+ ("TSLA", "Tesla Inc.", "Technology", 0.20),
61
+ ("AMD", "AMD Inc.", "Technology", 0.14),
62
+
63
+ # HEALTHCARE (6 stocks) - moderate returns
64
+ # Solver can pick max 5, so must choose best 5 from 6
65
+ ("JNJ", "Johnson & Johnson", "Healthcare", 0.09),
66
+ ("UNH", "UnitedHealth Group", "Healthcare", 0.11),
67
+ ("PFE", "Pfizer Inc.", "Healthcare", 0.07),
68
+ ("ABBV", "AbbVie Inc.", "Healthcare", 0.10),
69
+ ("TMO", "Thermo Fisher", "Healthcare", 0.13),
70
+ ("DHR", "Danaher Corp.", "Healthcare", 0.12),
71
+
72
+ # FINANCE (6 stocks) - stable returns
73
+ # Solver can pick max 5, so must choose best 5 from 6
74
+ ("JPM", "JPMorgan Chase", "Finance", 0.08),
75
+ ("BAC", "Bank of America", "Finance", 0.06),
76
+ ("WFC", "Wells Fargo", "Finance", 0.07),
77
+ ("GS", "Goldman Sachs", "Finance", 0.09),
78
+ ("MS", "Morgan Stanley", "Finance", 0.08),
79
+ ("C", "Citigroup", "Finance", 0.05),
80
+
81
+ # ENERGY (6 stocks) - variable returns
82
+ # Solver can pick max 5, so must choose best 5 from 6
83
+ ("XOM", "Exxon Mobil", "Energy", 0.04),
84
+ ("CVX", "Chevron Corp.", "Energy", 0.05),
85
+ ("COP", "ConocoPhillips", "Energy", 0.06),
86
+ ("SLB", "Schlumberger", "Energy", 0.03),
87
+ ("EOG", "EOG Resources", "Energy", 0.07),
88
+ ("PXD", "Pioneer Natural", "Energy", 0.08),
89
+ ]
90
+
91
+ LARGE_DATASET_STOCKS = SMALL_DATASET_STOCKS + [
92
+ # Additional TECHNOLOGY (6 more -> 13 total)
93
+ ("CRM", "Salesforce", "Technology", 0.11),
94
+ ("ADBE", "Adobe Inc.", "Technology", 0.09),
95
+ ("ORCL", "Oracle Corp.", "Technology", 0.07),
96
+ ("CSCO", "Cisco Systems", "Technology", 0.06),
97
+ ("IBM", "IBM Corp.", "Technology", 0.04),
98
+ ("QCOM", "Qualcomm", "Technology", 0.13),
99
+
100
+ # Additional HEALTHCARE (6 more -> 12 total)
101
+ ("MRK", "Merck & Co.", "Healthcare", 0.08),
102
+ ("LLY", "Eli Lilly", "Healthcare", 0.16),
103
+ ("BMY", "Bristol-Myers", "Healthcare", 0.06),
104
+ ("AMGN", "Amgen Inc.", "Healthcare", 0.09),
105
+ ("GILD", "Gilead Sciences", "Healthcare", 0.05),
106
+ ("ISRG", "Intuitive Surgical", "Healthcare", 0.14),
107
+
108
+ # Additional FINANCE (4 more -> 10 total, no duplicates)
109
+ ("AXP", "American Express", "Finance", 0.10),
110
+ ("BLK", "BlackRock", "Finance", 0.11),
111
+ ("SCHW", "Charles Schwab", "Finance", 0.07),
112
+ ("USB", "U.S. Bancorp", "Finance", 0.04),
113
+
114
+ # Additional ENERGY (2 more -> 8 total, no duplicates)
115
+ ("OXY", "Occidental Petroleum", "Energy", 0.06),
116
+ ("HAL", "Halliburton", "Energy", 0.05),
117
+
118
+ # CONSUMER (new sector - 8 stocks)
119
+ ("AMZN", "Amazon.com", "Consumer", 0.14),
120
+ ("WMT", "Walmart", "Consumer", 0.06),
121
+ ("HD", "Home Depot", "Consumer", 0.08),
122
+ ("MCD", "McDonald's", "Consumer", 0.07),
123
+ ("NKE", "Nike Inc.", "Consumer", 0.09),
124
+ ("SBUX", "Starbucks", "Consumer", 0.05),
125
+ ("PG", "Procter & Gamble", "Consumer", 0.04),
126
+ ("KO", "Coca-Cola", "Consumer", 0.05),
127
+ ]
128
+ # LARGE total: 25 + 6 + 6 + 4 + 2 + 8 = 51 stocks
129
+
130
+
131
+ def generate_demo_data(demo_data: DemoData) -> PortfolioOptimizationPlan:
132
+ """
133
+ Generate demo data for portfolio optimization.
134
+
135
+ Args:
136
+ demo_data: Which demo dataset to generate (SMALL or LARGE)
137
+
138
+ Returns:
139
+ PortfolioOptimizationPlan with candidate stocks (all unselected initially)
140
+
141
+ Example:
142
+ >>> plan = generate_demo_data(DemoData.SMALL)
143
+ >>> len(plan.stocks)
144
+ 20
145
+ >>> plan.stocks[0].stock_id
146
+ 'AAPL'
147
+ """
148
+ config = demo_data_configs[demo_data]
149
+ stock_data = SMALL_DATASET_STOCKS if demo_data == DemoData.SMALL else LARGE_DATASET_STOCKS
150
+
151
+ stocks = [
152
+ StockSelection(
153
+ stock_id=ticker,
154
+ stock_name=name,
155
+ sector=sector,
156
+ predicted_return=predicted_return,
157
+ selection=None, # To be decided by solver
158
+ )
159
+ for ticker, name, sector, predicted_return in stock_data
160
+ ]
161
+
162
+ # Calculate max_per_sector from percentage
163
+ target_count = config.target_position_count
164
+ max_per_sector = max(1, int(config.max_sector_percentage * target_count))
165
+
166
+ # Create PortfolioConfig for constraints to access
167
+ portfolio_config = PortfolioConfig(
168
+ target_count=target_count,
169
+ max_per_sector=max_per_sector,
170
+ unselected_penalty=10000,
171
+ )
172
+
173
+ return PortfolioOptimizationPlan(
174
+ stocks=stocks,
175
+ target_position_count=config.target_position_count,
176
+ max_sector_percentage=config.max_sector_percentage,
177
+ portfolio_config=portfolio_config,
178
+ )
179
+
180
+
181
+ def get_stock_summary(plan: PortfolioOptimizationPlan) -> str:
182
+ """
183
+ Generate a human-readable summary of the portfolio.
184
+
185
+ Useful for debugging and understanding the solution.
186
+ """
187
+ lines = [
188
+ "=" * 60,
189
+ "PORTFOLIO SUMMARY",
190
+ "=" * 60,
191
+ ]
192
+
193
+ selected = plan.get_selected_stocks()
194
+ if not selected:
195
+ lines.append("No stocks selected yet.")
196
+ return "\n".join(lines)
197
+
198
+ weight = plan.get_weight_per_stock()
199
+ expected_return = plan.get_expected_return()
200
+
201
+ lines.append(f"Selected: {len(selected)} stocks @ {weight*100:.1f}% each")
202
+ lines.append(f"Expected Return: {expected_return*100:.2f}%")
203
+ lines.append("")
204
+
205
+ # Group by sector
206
+ sector_stocks: dict[str, list[StockSelection]] = {}
207
+ for stock in selected:
208
+ if stock.sector not in sector_stocks:
209
+ sector_stocks[stock.sector] = []
210
+ sector_stocks[stock.sector].append(stock)
211
+
212
+ lines.append("BY SECTOR:")
213
+ for sector, stocks in sorted(sector_stocks.items()):
214
+ sector_weight = len(stocks) * weight * 100
215
+ lines.append(f" {sector}: {len(stocks)} stocks = {sector_weight:.1f}%")
216
+ for stock in sorted(stocks, key=lambda s: -s.predicted_return):
217
+ lines.append(f" - {stock.stock_id}: {stock.stock_name} ({stock.predicted_return*100:.1f}% pred)")
218
+
219
+ lines.append("")
220
+ lines.append(f"Score: {plan.score}")
221
+ lines.append("=" * 60)
222
+
223
+ return "\n".join(lines)
src/portfolio_optimization/domain.py ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Portfolio Optimization Domain Model
3
+
4
+ This module defines the core domain entities for stock portfolio optimization:
5
+ - StockSelection: A stock that can be selected for the portfolio (planning entity)
6
+ - PortfolioOptimizationPlan: The complete portfolio optimization problem (planning solution)
7
+
8
+ The model uses a Boolean selection approach:
9
+ - Each stock has a `selected` field (True/False)
10
+ - Selected stocks get equal weight (100% / number_selected)
11
+ - This simplifies the optimization while still demonstrating constraint solving
12
+ """
13
+ from solverforge_legacy.solver import SolverStatus
14
+ from solverforge_legacy.solver.domain import (
15
+ planning_entity,
16
+ planning_solution,
17
+ PlanningId,
18
+ PlanningVariable,
19
+ PlanningEntityCollectionProperty,
20
+ ProblemFactCollectionProperty,
21
+ ProblemFactProperty,
22
+ ValueRangeProvider,
23
+ PlanningScore,
24
+ )
25
+ from solverforge_legacy.solver.score import HardSoftScore
26
+ from typing import Annotated, List, Optional
27
+ from dataclasses import dataclass, field
28
+ from .json_serialization import JsonDomainBase
29
+ from pydantic import Field
30
+
31
+
32
+ @dataclass
33
+ class SelectionValue:
34
+ """
35
+ Represents a possible selection state for a stock.
36
+
37
+ We use this wrapper class instead of raw bool because SolverForge
38
+ needs a reference type for the value range provider.
39
+ """
40
+ value: bool
41
+
42
+ def __hash__(self):
43
+ return hash(self.value)
44
+
45
+ def __eq__(self, other):
46
+ if isinstance(other, SelectionValue):
47
+ return self.value == other.value
48
+ return False
49
+
50
+
51
+ # Pre-created selection values for the value range
52
+ SELECTED = SelectionValue(True)
53
+ NOT_SELECTED = SelectionValue(False)
54
+
55
+
56
+ @dataclass
57
+ class PortfolioConfig:
58
+ """
59
+ Configuration parameters for portfolio constraints.
60
+
61
+ This is a problem fact that constraints can join against to access
62
+ configurable threshold values.
63
+
64
+ Attributes:
65
+ target_count: Number of stocks to select (default 20)
66
+ max_per_sector: Maximum stocks per sector (default 5, which is 25% of 20)
67
+ unselected_penalty: Soft penalty per unselected stock (default 10000)
68
+ """
69
+ target_count: int = 20
70
+ max_per_sector: int = 5
71
+ unselected_penalty: int = 10000
72
+
73
+ def __hash__(self) -> int:
74
+ return hash((self.target_count, self.max_per_sector, self.unselected_penalty))
75
+
76
+ def __eq__(self, other: object) -> bool:
77
+ if isinstance(other, PortfolioConfig):
78
+ return (
79
+ self.target_count == other.target_count
80
+ and self.max_per_sector == other.max_per_sector
81
+ and self.unselected_penalty == other.unselected_penalty
82
+ )
83
+ return False
84
+
85
+
86
+ @planning_entity
87
+ @dataclass
88
+ class StockSelection:
89
+ """
90
+ Represents a stock that can be included in the portfolio.
91
+
92
+ This is a planning entity - SolverForge decides whether to include
93
+ each stock by setting the `selection` field.
94
+
95
+ Attributes:
96
+ stock_id: Unique identifier (ticker symbol, e.g., "AAPL")
97
+ stock_name: Human-readable name (e.g., "Apple Inc.")
98
+ sector: Industry sector (e.g., "Technology", "Healthcare")
99
+ predicted_return: ML-predicted return as decimal (0.12 = 12%)
100
+ selection: Planning variable - SELECTED or NOT_SELECTED
101
+ """
102
+ stock_id: Annotated[str, PlanningId]
103
+ stock_name: str
104
+ sector: str
105
+ predicted_return: float # e.g., 0.12 means 12% expected return
106
+
107
+ # THE DECISION: Should we include this stock in the portfolio?
108
+ # SolverForge will set this to SELECTED or NOT_SELECTED
109
+ # Note: value_range_provider_refs links to the 'selection_range' field
110
+ selection: Annotated[
111
+ SelectionValue | None,
112
+ PlanningVariable(value_range_provider_refs=["selection_range"])
113
+ ] = None
114
+
115
+ @property
116
+ def selected(self) -> bool | None:
117
+ """Convenience property to check if stock is selected."""
118
+ if self.selection is None:
119
+ return None
120
+ return self.selection.value
121
+
122
+
123
+ @planning_solution
124
+ @dataclass
125
+ class PortfolioOptimizationPlan:
126
+ """
127
+ The complete portfolio optimization problem.
128
+
129
+ This is the planning solution that contains:
130
+ - All candidate stocks (planning entities)
131
+ - Configuration parameters
132
+ - The optimization score
133
+
134
+ The solver will decide which stocks to select (set selected=True)
135
+ while respecting constraints and maximizing expected return.
136
+ """
137
+ # All stocks we're choosing from (planning entities)
138
+ stocks: Annotated[
139
+ list[StockSelection],
140
+ PlanningEntityCollectionProperty,
141
+ ValueRangeProvider
142
+ ]
143
+
144
+ # Configuration
145
+ target_position_count: int = 20 # How many stocks to select
146
+ max_sector_percentage: float = 0.25 # Max 25% in any sector
147
+
148
+ # Constraint configuration (problem fact for constraints to access)
149
+ # This derives from target_position_count and max_sector_percentage
150
+ portfolio_config: Annotated[
151
+ PortfolioConfig,
152
+ ProblemFactProperty
153
+ ] = field(default_factory=PortfolioConfig)
154
+
155
+ # Value range for the selection
156
+ # The solver can set `selection` to SELECTED or NOT_SELECTED
157
+ # Note: id="selection_range" must match the value_range_provider_refs in StockSelection
158
+ selection_range: Annotated[
159
+ list[SelectionValue],
160
+ ValueRangeProvider(id="selection_range"),
161
+ ProblemFactCollectionProperty
162
+ ] = field(default_factory=lambda: [SELECTED, NOT_SELECTED])
163
+
164
+ # Solution quality score (set by solver)
165
+ score: Annotated[HardSoftScore | None, PlanningScore] = None
166
+
167
+ # Current solver status
168
+ solver_status: SolverStatus = SolverStatus.NOT_SOLVING
169
+
170
+ def get_selected_stocks(self) -> list[StockSelection]:
171
+ """Return only stocks that are selected for the portfolio."""
172
+ return [s for s in self.stocks if s.selected is True]
173
+
174
+ def get_selected_count(self) -> int:
175
+ """Return count of selected stocks."""
176
+ return len(self.get_selected_stocks())
177
+
178
+ def get_weight_per_stock(self) -> float:
179
+ """Calculate equal weight per selected stock (e.g., 20 stocks = 5% each)."""
180
+ count = self.get_selected_count()
181
+ return 1.0 / count if count > 0 else 0.0
182
+
183
+ def get_sector_weights(self) -> dict[str, float]:
184
+ """Calculate total weight per sector."""
185
+ weight = self.get_weight_per_stock()
186
+ sector_weights: dict[str, float] = {}
187
+ for stock in self.get_selected_stocks():
188
+ sector_weights[stock.sector] = sector_weights.get(stock.sector, 0.0) + weight
189
+ return sector_weights
190
+
191
+ def get_expected_return(self) -> float:
192
+ """Calculate total expected portfolio return."""
193
+ weight = self.get_weight_per_stock()
194
+ return sum(s.predicted_return * weight for s in self.get_selected_stocks())
195
+
196
+ def get_herfindahl_index(self) -> float:
197
+ """
198
+ Calculate the Herfindahl-Hirschman Index (HHI) for sector concentration.
199
+
200
+ HHI = sum of (sector_weight)^2
201
+ - Range: 1/n (perfectly diversified) to 1.0 (all in one sector)
202
+ - Lower HHI = more diversified
203
+ - Common thresholds: <0.15 (diversified), 0.15-0.25 (moderate), >0.25 (concentrated)
204
+ """
205
+ sector_weights = self.get_sector_weights()
206
+ if not sector_weights:
207
+ return 0.0
208
+ return sum(w * w for w in sector_weights.values())
209
+
210
+ def get_diversification_score(self) -> float:
211
+ """
212
+ Calculate diversification score as 1 - HHI.
213
+
214
+ Range: 0.0 (all in one sector) to 1-1/n (perfectly diversified)
215
+ Higher = more diversified
216
+ """
217
+ return 1.0 - self.get_herfindahl_index()
218
+
219
+ def get_max_sector_exposure(self) -> float:
220
+ """
221
+ Get the highest single sector weight.
222
+
223
+ Returns the weight of the most concentrated sector.
224
+ Lower is better for diversification.
225
+ """
226
+ sector_weights = self.get_sector_weights()
227
+ if not sector_weights:
228
+ return 0.0
229
+ return max(sector_weights.values())
230
+
231
+ def get_sector_count(self) -> int:
232
+ """Return count of unique sectors in selected stocks."""
233
+ selected = self.get_selected_stocks()
234
+ return len(set(s.sector for s in selected))
235
+
236
+ def get_return_volatility(self) -> float:
237
+ """
238
+ Calculate standard deviation of predicted returns (proxy for risk).
239
+
240
+ Higher volatility = higher risk portfolio.
241
+ """
242
+ selected = self.get_selected_stocks()
243
+ if len(selected) < 2:
244
+ return 0.0
245
+
246
+ returns = [s.predicted_return for s in selected]
247
+ mean_return = sum(returns) / len(returns)
248
+ variance = sum((r - mean_return) ** 2 for r in returns) / len(returns)
249
+ return variance ** 0.5
250
+
251
+ def get_sharpe_proxy(self) -> float:
252
+ """
253
+ Calculate a proxy for Sharpe ratio: return / volatility.
254
+
255
+ Higher = better risk-adjusted return.
256
+ Note: This is a simplified proxy, not true Sharpe (no risk-free rate).
257
+ """
258
+ volatility = self.get_return_volatility()
259
+ if volatility == 0:
260
+ return 0.0
261
+ return self.get_expected_return() / volatility
262
+
263
+
264
+ # ============================================================
265
+ # Pydantic REST Models (for API serialization)
266
+ # ============================================================
267
+
268
+ class StockSelectionModel(JsonDomainBase):
269
+ """REST API model for StockSelection."""
270
+ stock_id: str = Field(..., alias="stockId")
271
+ stock_name: str = Field(..., alias="stockName")
272
+ sector: str
273
+ predicted_return: float = Field(..., alias="predictedReturn")
274
+ selected: Optional[bool] = None
275
+
276
+
277
+ class SolverConfigModel(JsonDomainBase):
278
+ """REST API model for solver configuration options."""
279
+ termination_seconds: int = Field(default=30, alias="terminationSeconds", ge=10, le=300)
280
+
281
+
282
+ class PortfolioMetricsModel(JsonDomainBase):
283
+ """
284
+ REST API model for portfolio business metrics (KPIs).
285
+
286
+ These metrics provide business insight beyond the solver score:
287
+ - Diversification measures (HHI, max sector exposure)
288
+ - Risk/return measures (expected return, volatility, Sharpe proxy)
289
+ """
290
+ expected_return: float = Field(..., alias="expectedReturn")
291
+ sector_count: int = Field(..., alias="sectorCount")
292
+ max_sector_exposure: float = Field(..., alias="maxSectorExposure")
293
+ herfindahl_index: float = Field(..., alias="herfindahlIndex")
294
+ diversification_score: float = Field(..., alias="diversificationScore")
295
+ return_volatility: float = Field(..., alias="returnVolatility")
296
+ sharpe_proxy: float = Field(..., alias="sharpeProxy")
297
+
298
+
299
+ class PortfolioOptimizationPlanModel(JsonDomainBase):
300
+ """REST API model for PortfolioOptimizationPlan."""
301
+ stocks: List[StockSelectionModel]
302
+ target_position_count: int = Field(default=20, alias="targetPositionCount")
303
+ max_sector_percentage: float = Field(default=0.25, alias="maxSectorPercentage")
304
+ score: Optional[str] = None
305
+ solver_status: Optional[str] = Field(None, alias="solverStatus")
306
+ solver_config: Optional[SolverConfigModel] = Field(None, alias="solverConfig")
307
+ metrics: Optional[PortfolioMetricsModel] = None
src/portfolio_optimization/json_serialization.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ JSON serialization utilities for the Portfolio Optimization quickstart.
3
+
4
+ Provides Pydantic configuration for REST API models with camelCase support.
5
+ """
6
+ from solverforge_legacy.solver.score import HardSoftScore
7
+ from typing import Any
8
+ from pydantic import BaseModel, ConfigDict, PlainSerializer, BeforeValidator
9
+ from pydantic.alias_generators import to_camel
10
+
11
+
12
+ ScoreSerializer = PlainSerializer(
13
+ lambda score: str(score) if score is not None else None, return_type=str | None
14
+ )
15
+
16
+
17
+ def validate_score(v: Any) -> Any:
18
+ """Validate and parse score from string or HardSoftScore."""
19
+ if isinstance(v, HardSoftScore) or v is None:
20
+ return v
21
+ if isinstance(v, str):
22
+ return HardSoftScore.parse(v)
23
+ raise ValueError('"score" should be a string')
24
+
25
+
26
+ ScoreValidator = BeforeValidator(validate_score)
27
+
28
+
29
+ class JsonDomainBase(BaseModel):
30
+ """
31
+ Base class for Pydantic REST models.
32
+
33
+ Provides:
34
+ - Automatic camelCase conversion for JSON keys
35
+ - Support for both camelCase and snake_case in input
36
+ - Attribute access from dataclass instances
37
+ """
38
+ model_config = ConfigDict(
39
+ alias_generator=to_camel,
40
+ populate_by_name=True,
41
+ from_attributes=True,
42
+ )
src/portfolio_optimization/rest_api.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ REST API for Portfolio Optimization
3
+
4
+ This module provides HTTP endpoints for the portfolio optimization quickstart:
5
+
6
+ Endpoints:
7
+ - GET /demo-data - List available demo datasets
8
+ - GET /demo-data/{id} - Load a specific demo dataset
9
+ - POST /portfolios - Submit a portfolio for optimization
10
+ - GET /portfolios/{id} - Get current solution for a job
11
+ - GET /portfolios/{id}/status - Get solving status
12
+ - DELETE /portfolios/{id} - Stop solving
13
+ - PUT /portfolios/analyze - Analyze a submitted portfolio's score
14
+
15
+ The API follows the same patterns as other SolverForge quickstarts.
16
+ """
17
+ from fastapi import FastAPI, Request
18
+ from fastapi.staticfiles import StaticFiles
19
+ from uuid import uuid4
20
+ from dataclasses import replace
21
+ from typing import Any
22
+
23
+ from solverforge_legacy.solver import SolverManager, SolverFactory
24
+
25
+ from .domain import PortfolioOptimizationPlan, PortfolioOptimizationPlanModel
26
+ from .converters import plan_to_model, model_to_plan
27
+ from .demo_data import DemoData, generate_demo_data
28
+ from .solver import solver_manager, solution_manager, create_solver_config
29
+ from .score_analysis import ConstraintAnalysisDTO, MatchAnalysisDTO
30
+
31
+
32
+ app = FastAPI(
33
+ title="Portfolio Optimization Quickstart",
34
+ description="SolverForge quickstart for stock portfolio optimization",
35
+ docs_url='/q/swagger-ui'
36
+ )
37
+
38
+ # In-memory storage for submitted portfolios and their solver managers
39
+ data_sets: dict[str, PortfolioOptimizationPlan] = {}
40
+ solver_managers: dict[str, SolverManager] = {}
41
+
42
+
43
+ @app.get("/demo-data")
44
+ async def demo_data_list() -> list[DemoData]:
45
+ """List available demo datasets."""
46
+ return [e for e in DemoData]
47
+
48
+
49
+ @app.get("/demo-data/{dataset_id}", response_model_exclude_none=True)
50
+ async def get_demo_data(dataset_id: str) -> PortfolioOptimizationPlanModel:
51
+ """Load a specific demo dataset."""
52
+ demo_data = getattr(DemoData, dataset_id)
53
+ domain_plan = generate_demo_data(demo_data)
54
+ return plan_to_model(domain_plan)
55
+
56
+
57
+ @app.get("/portfolios/{problem_id}", response_model_exclude_none=True)
58
+ async def get_portfolio(problem_id: str) -> PortfolioOptimizationPlanModel:
59
+ """Get current solution for a portfolio optimization job."""
60
+ plan = data_sets[problem_id]
61
+ # Use per-job solver manager if available, otherwise use default
62
+ manager = solver_managers.get(problem_id, solver_manager)
63
+ updated_plan = replace(plan, solver_status=manager.get_solver_status(problem_id))
64
+ return plan_to_model(updated_plan)
65
+
66
+
67
+ def update_portfolio(problem_id: str, plan: PortfolioOptimizationPlan) -> None:
68
+ """Callback to update the stored solution as solver improves it."""
69
+ global data_sets
70
+ data_sets[problem_id] = plan
71
+
72
+
73
+ @app.post("/portfolios")
74
+ async def solve_portfolio(plan_model: PortfolioOptimizationPlanModel) -> str:
75
+ """
76
+ Submit a portfolio for optimization.
77
+
78
+ Returns a job ID that can be used to retrieve the solution.
79
+ Supports custom solver configuration via solverConfig field.
80
+ """
81
+ job_id = str(uuid4())
82
+ plan = model_to_plan(plan_model)
83
+ data_sets[job_id] = plan
84
+
85
+ # Get termination time from config or use default
86
+ termination_seconds = 30
87
+ if plan_model.solver_config and plan_model.solver_config.termination_seconds:
88
+ termination_seconds = plan_model.solver_config.termination_seconds
89
+
90
+ # Create solver with dynamic config
91
+ config = create_solver_config(termination_seconds)
92
+ manager: SolverManager = SolverManager.create(SolverFactory.create(config))
93
+ solver_managers[job_id] = manager
94
+
95
+ manager.solve_and_listen(
96
+ job_id,
97
+ plan,
98
+ lambda solution: update_portfolio(job_id, solution)
99
+ )
100
+ return job_id
101
+
102
+
103
+ @app.get("/portfolios")
104
+ async def list_portfolios() -> list[str]:
105
+ """List all job IDs of submitted portfolios."""
106
+ return list(data_sets.keys())
107
+
108
+
109
+ @app.get("/portfolios/{problem_id}/status")
110
+ async def get_status(problem_id: str) -> dict[str, Any]:
111
+ """Get the portfolio status and score for a given job ID."""
112
+ if problem_id not in data_sets:
113
+ raise ValueError(f"No portfolio found with ID {problem_id}")
114
+
115
+ plan = data_sets[problem_id]
116
+ # Use per-job solver manager if available, otherwise use default
117
+ manager = solver_managers.get(problem_id, solver_manager)
118
+ solver_status = manager.get_solver_status(problem_id)
119
+
120
+ # Calculate additional metrics
121
+ selected_count = plan.get_selected_count()
122
+ expected_return = plan.get_expected_return() if selected_count > 0 else 0
123
+
124
+ return {
125
+ "score": {
126
+ "hardScore": plan.score.hard_score if plan.score else 0,
127
+ "softScore": plan.score.soft_score if plan.score else 0,
128
+ },
129
+ "solverStatus": solver_status.name,
130
+ "selectedCount": selected_count,
131
+ "expectedReturn": expected_return,
132
+ "sectorWeights": plan.get_sector_weights() if selected_count > 0 else {},
133
+ }
134
+
135
+
136
+ @app.delete("/portfolios/{problem_id}")
137
+ async def stop_solving(problem_id: str) -> PortfolioOptimizationPlanModel:
138
+ """Terminate solving for a given job ID."""
139
+ if problem_id not in data_sets:
140
+ raise ValueError(f"No portfolio found with ID {problem_id}")
141
+
142
+ # Use per-job solver manager if available, otherwise use default
143
+ manager = solver_managers.get(problem_id, solver_manager)
144
+ try:
145
+ manager.terminate_early(problem_id)
146
+ except Exception as e:
147
+ print(f"Warning: terminate_early failed for {problem_id}: {e}")
148
+
149
+ return await get_portfolio(problem_id)
150
+
151
+
152
+ @app.put("/portfolios/analyze")
153
+ async def analyze_portfolio(request: Request) -> dict[str, Any]:
154
+ """Submit a portfolio to analyze its score in detail."""
155
+ json_data = await request.json()
156
+
157
+ # Parse the incoming JSON using Pydantic models
158
+ plan_model = PortfolioOptimizationPlanModel.model_validate(json_data)
159
+
160
+ # Convert to domain model for analysis
161
+ domain_plan = model_to_plan(plan_model)
162
+
163
+ analysis = solution_manager.analyze(domain_plan)
164
+
165
+ # Convert to DTOs for proper serialization
166
+ constraints = []
167
+ for constraint in getattr(analysis, 'constraint_analyses', []) or []:
168
+ matches = [
169
+ MatchAnalysisDTO(
170
+ name=str(getattr(getattr(match, 'constraint_ref', None), 'constraint_name', "")),
171
+ score=str(getattr(match, 'score', "0hard/0soft")),
172
+ justification=str(getattr(match, 'justification', "")),
173
+ )
174
+ for match in getattr(constraint, 'matches', []) or []
175
+ ]
176
+
177
+ constraint_dto = ConstraintAnalysisDTO(
178
+ name=str(getattr(constraint, 'constraint_name', "")),
179
+ weight=str(getattr(constraint, 'weight', "0hard/0soft")),
180
+ score=str(getattr(constraint, 'score', "0hard/0soft")),
181
+ matches=matches,
182
+ )
183
+ constraints.append(constraint_dto)
184
+
185
+ return {"constraints": [constraint.model_dump() for constraint in constraints]}
186
+
187
+
188
+ # Mount static files for the web UI
189
+ app.mount("/", StaticFiles(directory="static", html=True), name="static")
src/portfolio_optimization/score_analysis.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Score Analysis DTOs for Portfolio Optimization.
3
+
4
+ These data transfer objects are used for the /analyze endpoint
5
+ to provide detailed constraint-by-constraint score breakdown.
6
+ """
7
+ from pydantic import BaseModel
8
+ from typing import List
9
+
10
+
11
+ class MatchAnalysisDTO(BaseModel):
12
+ """A single constraint match (violation or reward)."""
13
+ name: str
14
+ score: str
15
+ justification: str
16
+
17
+
18
+ class ConstraintAnalysisDTO(BaseModel):
19
+ """Analysis of a single constraint across all matches."""
20
+ name: str
21
+ weight: str
22
+ score: str
23
+ matches: List[MatchAnalysisDTO]
src/portfolio_optimization/solver.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Solver Configuration for Portfolio Optimization
3
+
4
+ This module sets up the SolverForge solver with:
5
+ - Solution class (PortfolioOptimizationPlan)
6
+ - Entity class (StockSelection)
7
+ - Constraint provider (define_constraints)
8
+ - Termination config (configurable, default 30 seconds)
9
+
10
+ The solver explores different stock selections and finds the best
11
+ portfolio that satisfies all constraints while maximizing return.
12
+ """
13
+ from solverforge_legacy.solver import SolverManager, SolverFactory, SolutionManager
14
+ from solverforge_legacy.solver.config import (
15
+ SolverConfig,
16
+ ScoreDirectorFactoryConfig,
17
+ TerminationConfig,
18
+ Duration,
19
+ )
20
+
21
+ from .domain import PortfolioOptimizationPlan, StockSelection
22
+ from .constraints import define_constraints
23
+
24
+
25
+ def create_solver_config(termination_seconds: int = 30) -> SolverConfig:
26
+ """
27
+ Create a solver configuration with specified termination time.
28
+
29
+ Args:
30
+ termination_seconds: How long to run the solver (default 30 seconds)
31
+
32
+ Returns:
33
+ SolverConfig configured for portfolio optimization
34
+ """
35
+ return SolverConfig(
36
+ # The solution class that contains all entities
37
+ solution_class=PortfolioOptimizationPlan,
38
+
39
+ # The entity classes that the solver modifies
40
+ entity_class_list=[StockSelection],
41
+
42
+ # The constraint provider that defines business rules
43
+ score_director_factory_config=ScoreDirectorFactoryConfig(
44
+ constraint_provider_function=define_constraints
45
+ ),
46
+
47
+ # How long to run the solver
48
+ termination_config=TerminationConfig(spent_limit=Duration(seconds=termination_seconds)),
49
+ )
50
+
51
+
52
+ # Default solver config (30 seconds)
53
+ solver_config: SolverConfig = create_solver_config()
54
+
55
+ # Create default solver manager for handling solve requests
56
+ solver_manager: SolverManager = SolverManager.create(SolverFactory.create(solver_config))
57
+
58
+ # Create solution manager for analyzing solutions
59
+ solution_manager: SolutionManager = SolutionManager.create(solver_manager)
static/app.js ADDED
@@ -0,0 +1,851 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Portfolio Optimization Quickstart - Frontend Application
3
+ */
4
+
5
+ // =============================================================================
6
+ // Global State
7
+ // =============================================================================
8
+
9
+ let autoRefreshIntervalId = null;
10
+ let demoDataId = "SMALL";
11
+ let scheduleId = null;
12
+ let loadedPlan = null;
13
+
14
+ // Chart instances
15
+ let sectorChart = null;
16
+ let returnsChart = null;
17
+
18
+ // Sorting state for stock table
19
+ let sortColumn = 'predictedReturn';
20
+ let sortDirection = 'desc';
21
+
22
+ // Sector colors (consistent across charts and badges)
23
+ const SECTOR_COLORS = {
24
+ 'Technology': '#3b82f6',
25
+ 'Healthcare': '#10b981',
26
+ 'Finance': '#eab308',
27
+ 'Energy': '#ef4444',
28
+ 'Consumer': '#a855f7'
29
+ };
30
+
31
+ // =============================================================================
32
+ // Initialization
33
+ // =============================================================================
34
+
35
+ $(document).ready(function() {
36
+ // Initialize header/footer with nav tabs
37
+ replaceQuickstartSolverForgeAutoHeaderFooter();
38
+
39
+ // Initialize Bootstrap tooltips
40
+ const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
41
+ [...tooltipTriggerList].map(el => new bootstrap.Tooltip(el));
42
+
43
+ // Load initial data
44
+ loadDemoData();
45
+
46
+ // Event handlers
47
+ $("#solveButton").click(solve);
48
+ $("#stopSolvingButton").click(stopSolving);
49
+ $("#analyzeButton").click(analyze);
50
+ $("#dataSelector").change(function() {
51
+ demoDataId = $(this).val();
52
+ loadDemoData();
53
+ });
54
+ $("#showSelectedOnly").change(renderAllStocksTable);
55
+
56
+ // Sortable table headers
57
+ $("#allStocksTable").closest("table").find("th[data-sort]").click(function() {
58
+ const column = $(this).data("sort");
59
+ if (sortColumn === column) {
60
+ sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
61
+ } else {
62
+ sortColumn = column;
63
+ sortDirection = 'desc';
64
+ }
65
+ updateSortIndicators();
66
+ renderAllStocksTable();
67
+ });
68
+
69
+ // Initialize advanced configuration if available (from config.js)
70
+ if (typeof initAdvancedConfig === 'function') {
71
+ initAdvancedConfig();
72
+ }
73
+ });
74
+
75
+ // =============================================================================
76
+ // jQuery AJAX Extensions
77
+ // =============================================================================
78
+
79
+ $.ajaxSetup({
80
+ contentType: "application/json",
81
+ accepts: {
82
+ json: "application/json"
83
+ }
84
+ });
85
+
86
+ $.put = function(url, data) {
87
+ return $.ajax({
88
+ url: url,
89
+ type: "PUT",
90
+ data: JSON.stringify(data),
91
+ contentType: "application/json"
92
+ });
93
+ };
94
+
95
+ $.delete = function(url) {
96
+ return $.ajax({
97
+ url: url,
98
+ type: "DELETE"
99
+ });
100
+ };
101
+
102
+ // =============================================================================
103
+ // Data Loading
104
+ // =============================================================================
105
+
106
+ function loadDemoData() {
107
+ $.getJSON("/demo-data/" + demoDataId)
108
+ .done(function(data) {
109
+ loadedPlan = data;
110
+ scheduleId = null;
111
+ // Apply advanced config if available (from config.js)
112
+ if (typeof applyConfigToLoadedPlan === 'function') {
113
+ applyConfigToLoadedPlan();
114
+ }
115
+ refreshUI();
116
+ updateScore("Score: ?");
117
+ })
118
+ .fail(function(xhr) {
119
+ showError("Failed to load demo data", xhr);
120
+ });
121
+ }
122
+
123
+ // =============================================================================
124
+ // Solving
125
+ // =============================================================================
126
+
127
+ function solve() {
128
+ if (!loadedPlan) {
129
+ showSimpleError("Please load demo data first");
130
+ return;
131
+ }
132
+
133
+ $.ajax({
134
+ url: "/portfolios",
135
+ type: "POST",
136
+ data: JSON.stringify(loadedPlan),
137
+ contentType: "application/json"
138
+ })
139
+ .done(function(data) {
140
+ scheduleId = data;
141
+ refreshSolvingButtons(true);
142
+ })
143
+ .fail(function(xhr) {
144
+ showError("Failed to start solver", xhr);
145
+ });
146
+ }
147
+
148
+ function stopSolving() {
149
+ if (!scheduleId) return;
150
+
151
+ $.delete("/portfolios/" + scheduleId)
152
+ .done(function(data) {
153
+ loadedPlan = data;
154
+ refreshSolvingButtons(false);
155
+ refreshUI();
156
+ })
157
+ .fail(function(xhr) {
158
+ showError("Failed to stop solver", xhr);
159
+ });
160
+ }
161
+
162
+ function refreshSolvingButtons(solving) {
163
+ if (solving) {
164
+ $("#solveButton").hide();
165
+ $("#stopSolvingButton").show();
166
+ $("#solvingSpinner").addClass("active");
167
+ $(".kpi-value").addClass("solving-pulse");
168
+
169
+ if (autoRefreshIntervalId == null) {
170
+ autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
171
+ }
172
+ } else {
173
+ $("#solveButton").show();
174
+ $("#stopSolvingButton").hide();
175
+ $("#solvingSpinner").removeClass("active");
176
+ $(".kpi-value").removeClass("solving-pulse");
177
+
178
+ if (autoRefreshIntervalId != null) {
179
+ clearInterval(autoRefreshIntervalId);
180
+ autoRefreshIntervalId = null;
181
+ }
182
+ }
183
+ }
184
+
185
+ function refreshSchedule() {
186
+ if (!scheduleId) return;
187
+
188
+ $.getJSON("/portfolios/" + scheduleId)
189
+ .done(function(data) {
190
+ loadedPlan = data;
191
+ refreshUI();
192
+
193
+ // Check if solver is done
194
+ if (data.solverStatus === "NOT_SOLVING") {
195
+ refreshSolvingButtons(false);
196
+ }
197
+ })
198
+ .fail(function(xhr) {
199
+ showError("Failed to refresh solution", xhr);
200
+ refreshSolvingButtons(false);
201
+ });
202
+ }
203
+
204
+ // =============================================================================
205
+ // Score Analysis
206
+ // =============================================================================
207
+
208
+ function analyze() {
209
+ if (!loadedPlan) {
210
+ showSimpleError("Please load demo data first");
211
+ return;
212
+ }
213
+
214
+ $.put("/portfolios/analyze", loadedPlan)
215
+ .done(function(data) {
216
+ showConstraintAnalysis(data);
217
+ })
218
+ .fail(function(xhr) {
219
+ showError("Failed to analyze constraints", xhr);
220
+ });
221
+ }
222
+
223
+ function showConstraintAnalysis(analysis) {
224
+ const tbody = $("#weightModalContent tbody");
225
+ tbody.empty();
226
+
227
+ // Update modal label with score
228
+ const score = loadedPlan.score || "?";
229
+ $("#weightModalLabel").text(score);
230
+
231
+ // Sort constraints: hard first, then by score impact
232
+ const constraints = analysis.constraints || [];
233
+ constraints.sort((a, b) => {
234
+ if (a.type !== b.type) {
235
+ return a.type === "HARD" ? -1 : 1;
236
+ }
237
+ return Math.abs(b.score) - Math.abs(a.score);
238
+ });
239
+
240
+ constraints.forEach(function(c) {
241
+ const icon = c.score === 0
242
+ ? '<i class="fas fa-check-circle text-success"></i>'
243
+ : '<i class="fas fa-exclamation-triangle text-danger"></i>';
244
+
245
+ const typeClass = c.type === "HARD" ? "text-danger" : "text-warning";
246
+ const scoreClass = c.score === 0 ? "text-success" : "text-danger";
247
+
248
+ tbody.append(`
249
+ <tr>
250
+ <td>${icon}</td>
251
+ <td>${c.name}</td>
252
+ <td><span class="${typeClass}">${c.type}</span></td>
253
+ <td>${c.weight || '-'}</td>
254
+ <td>${c.matchCount || 0}</td>
255
+ <td class="${scoreClass}">${c.score}</td>
256
+ </tr>
257
+ `);
258
+ });
259
+
260
+ // Show modal
261
+ const modal = new bootstrap.Modal(document.getElementById('weightModal'));
262
+ modal.show();
263
+ }
264
+
265
+ // =============================================================================
266
+ // UI Refresh
267
+ // =============================================================================
268
+
269
+ function refreshUI() {
270
+ updateKPIs();
271
+ updateScore();
272
+ updateCharts();
273
+ renderSelectedStocksTable();
274
+ renderAllStocksTable();
275
+ }
276
+
277
+ function updateKPIs() {
278
+ if (!loadedPlan || !loadedPlan.stocks) {
279
+ resetKPIs();
280
+ return;
281
+ }
282
+
283
+ const stocks = loadedPlan.stocks;
284
+ const selected = stocks.filter(s => s.selected);
285
+ const selectedCount = selected.length;
286
+
287
+ // Get target from plan, config module, or default to 20
288
+ let targetCount = loadedPlan.targetPositionCount || 20;
289
+ if (typeof getTargetStockCount === 'function') {
290
+ targetCount = getTargetStockCount();
291
+ }
292
+
293
+ // Selected stocks count (show target)
294
+ const selectedEl = $("#selectedCount");
295
+ selectedEl.text(selectedCount + "/" + targetCount);
296
+ if (selectedCount === targetCount) {
297
+ selectedEl.removeClass("text-warning").addClass("text-success");
298
+ } else if (selectedCount > 0) {
299
+ selectedEl.removeClass("text-success").addClass("text-warning");
300
+ } else {
301
+ selectedEl.removeClass("text-success text-warning");
302
+ }
303
+ $("#selectedBadge").text(selectedCount + " selected");
304
+
305
+ // Stock count badge
306
+ $("#stockCountBadge").text(stocks.length + " stocks");
307
+
308
+ // Use metrics from backend if available
309
+ if (loadedPlan.metrics) {
310
+ updateKPIsFromMetrics(loadedPlan.metrics);
311
+ } else {
312
+ // Fallback: calculate locally
313
+ updateKPIsLocally(selected, selectedCount);
314
+ }
315
+ }
316
+
317
+ function resetKPIs() {
318
+ $("#selectedCount").text("0/20");
319
+ $("#expectedReturn").text("0.00%").removeClass("positive negative");
320
+ $("#sectorCount").text("0");
321
+ $("#diversificationScore").text("0%").removeClass("text-success text-warning text-danger");
322
+ $("#maxSectorExposure").text("0%");
323
+ $("#returnVolatility").text("0.00%");
324
+ $("#sharpeProxy").text("0.00");
325
+ $("#herfindahlIndex").text("0.000");
326
+ $("#selectedBadge").text("0 selected");
327
+ }
328
+
329
+ function updateKPIsFromMetrics(metrics) {
330
+ // Expected return
331
+ const returnEl = $("#expectedReturn");
332
+ returnEl.text((metrics.expectedReturn * 100).toFixed(2) + "%");
333
+ returnEl.removeClass("positive negative");
334
+ returnEl.addClass(metrics.expectedReturn >= 0 ? "positive" : "negative");
335
+
336
+ // Sector count
337
+ $("#sectorCount").text(metrics.sectorCount);
338
+
339
+ // Diversification score (0-100%)
340
+ const divScore = (metrics.diversificationScore * 100).toFixed(0);
341
+ const divEl = $("#diversificationScore");
342
+ divEl.text(divScore + "%");
343
+ divEl.removeClass("text-success text-warning text-danger");
344
+ if (metrics.diversificationScore >= 0.7) {
345
+ divEl.addClass("text-success");
346
+ } else if (metrics.diversificationScore >= 0.5) {
347
+ divEl.addClass("text-warning");
348
+ } else {
349
+ divEl.addClass("text-danger");
350
+ }
351
+
352
+ // Max sector exposure
353
+ $("#maxSectorExposure").text((metrics.maxSectorExposure * 100).toFixed(1) + "%");
354
+
355
+ // Return volatility
356
+ $("#returnVolatility").text((metrics.returnVolatility * 100).toFixed(2) + "%");
357
+
358
+ // Sharpe proxy
359
+ const sharpeEl = $("#sharpeProxy");
360
+ sharpeEl.text(metrics.sharpeProxy.toFixed(2));
361
+ sharpeEl.removeClass("text-success text-warning text-danger");
362
+ if (metrics.sharpeProxy >= 1.0) {
363
+ sharpeEl.addClass("text-success");
364
+ } else if (metrics.sharpeProxy >= 0.5) {
365
+ sharpeEl.addClass("text-warning");
366
+ }
367
+
368
+ // HHI (Herfindahl-Hirschman Index)
369
+ const hhiEl = $("#herfindahlIndex");
370
+ hhiEl.text(metrics.herfindahlIndex.toFixed(3));
371
+ hhiEl.removeClass("text-success text-warning text-danger");
372
+ if (metrics.herfindahlIndex < 0.15) {
373
+ hhiEl.addClass("text-success");
374
+ } else if (metrics.herfindahlIndex < 0.25) {
375
+ hhiEl.addClass("text-warning");
376
+ } else {
377
+ hhiEl.addClass("text-danger");
378
+ }
379
+ }
380
+
381
+ function updateKPIsLocally(selected, selectedCount) {
382
+ // Expected return (weighted average)
383
+ const expectedReturn = selectedCount > 0
384
+ ? selected.reduce((sum, s) => sum + s.predictedReturn, 0) / selectedCount
385
+ : 0;
386
+ const returnEl = $("#expectedReturn");
387
+ returnEl.text((expectedReturn * 100).toFixed(2) + "%");
388
+ returnEl.removeClass("positive negative");
389
+ returnEl.addClass(expectedReturn >= 0 ? "positive" : "negative");
390
+
391
+ // Sector count
392
+ const sectors = new Set(selected.map(s => s.sector));
393
+ $("#sectorCount").text(sectors.size);
394
+
395
+ if (selectedCount > 0) {
396
+ // Calculate sector weights and HHI locally
397
+ const sectorCounts = {};
398
+ selected.forEach(s => {
399
+ sectorCounts[s.sector] = (sectorCounts[s.sector] || 0) + 1;
400
+ });
401
+
402
+ const sectorWeights = Object.values(sectorCounts).map(c => c / selectedCount);
403
+ const hhi = sectorWeights.reduce((sum, w) => sum + w * w, 0);
404
+ const divScore = ((1 - hhi) * 100).toFixed(0);
405
+ const maxSector = Math.max(...sectorWeights) * 100;
406
+
407
+ $("#diversificationScore").text(divScore + "%");
408
+ $("#maxSectorExposure").text(maxSector.toFixed(1) + "%");
409
+ $("#herfindahlIndex").text(hhi.toFixed(3));
410
+
411
+ // Calculate volatility locally
412
+ const returns = selected.map(s => s.predictedReturn);
413
+ const meanReturn = returns.reduce((a, b) => a + b, 0) / returns.length;
414
+ const variance = returns.reduce((sum, r) => sum + Math.pow(r - meanReturn, 2), 0) / returns.length;
415
+ const volatility = Math.sqrt(variance);
416
+
417
+ $("#returnVolatility").text((volatility * 100).toFixed(2) + "%");
418
+ $("#sharpeProxy").text(volatility > 0 ? (expectedReturn / volatility).toFixed(2) : "0.00");
419
+ } else {
420
+ $("#diversificationScore").text("0%");
421
+ $("#maxSectorExposure").text("0%");
422
+ $("#returnVolatility").text("0.00%");
423
+ $("#sharpeProxy").text("0.00");
424
+ $("#herfindahlIndex").text("0.000");
425
+ }
426
+ }
427
+
428
+ function updateScore() {
429
+ if (!loadedPlan || !loadedPlan.score) {
430
+ $("#score").text("Score: ?");
431
+ return;
432
+ }
433
+
434
+ const score = loadedPlan.score;
435
+ const match = score.match(/(-?\d+)hard\/(-?\d+)soft/);
436
+
437
+ if (match) {
438
+ const hard = parseInt(match[1]);
439
+ const soft = parseInt(match[2]);
440
+ const isFeasible = hard === 0;
441
+
442
+ const scoreHtml = `
443
+ <span class="score-badge ${isFeasible ? 'score-feasible' : 'score-infeasible'}">
444
+ ${hard}hard
445
+ </span>
446
+ <span class="score-badge score-soft ms-1">
447
+ ${soft}soft
448
+ </span>
449
+ `;
450
+ $("#score").html(scoreHtml);
451
+ } else {
452
+ $("#score").text("Score: " + score);
453
+ }
454
+ }
455
+
456
+ // =============================================================================
457
+ // Charts
458
+ // =============================================================================
459
+
460
+ function updateCharts() {
461
+ updateSectorChart();
462
+ updateReturnsChart();
463
+ }
464
+
465
+ function updateSectorChart() {
466
+ const ctx = document.getElementById('sectorChart');
467
+ if (!ctx) return;
468
+
469
+ if (!loadedPlan || !loadedPlan.stocks) {
470
+ if (sectorChart) {
471
+ sectorChart.destroy();
472
+ sectorChart = null;
473
+ }
474
+ return;
475
+ }
476
+
477
+ const selected = loadedPlan.stocks.filter(s => s.selected);
478
+
479
+ if (selected.length === 0) {
480
+ if (sectorChart) {
481
+ sectorChart.destroy();
482
+ sectorChart = null;
483
+ }
484
+ return;
485
+ }
486
+
487
+ // Calculate sector counts
488
+ const sectorCounts = {};
489
+ selected.forEach(s => {
490
+ sectorCounts[s.sector] = (sectorCounts[s.sector] || 0) + 1;
491
+ });
492
+
493
+ const labels = Object.keys(sectorCounts);
494
+ const data = labels.map(s => (sectorCounts[s] / selected.length) * 100);
495
+ const colors = labels.map(s => SECTOR_COLORS[s] || '#64748b');
496
+
497
+ if (sectorChart) {
498
+ sectorChart.destroy();
499
+ }
500
+
501
+ sectorChart = new Chart(ctx, {
502
+ type: 'doughnut',
503
+ data: {
504
+ labels: labels,
505
+ datasets: [{
506
+ data: data,
507
+ backgroundColor: colors,
508
+ borderWidth: 3,
509
+ borderColor: '#fff',
510
+ hoverOffset: 8
511
+ }]
512
+ },
513
+ options: {
514
+ responsive: true,
515
+ maintainAspectRatio: false,
516
+ cutout: '60%',
517
+ plugins: {
518
+ legend: {
519
+ position: 'right',
520
+ labels: {
521
+ padding: 16,
522
+ usePointStyle: true,
523
+ pointStyle: 'circle'
524
+ }
525
+ },
526
+ tooltip: {
527
+ callbacks: {
528
+ label: function(context) {
529
+ const value = context.raw.toFixed(1);
530
+ const count = sectorCounts[context.label];
531
+ return `${context.label}: ${value}% (${count} stocks)`;
532
+ }
533
+ }
534
+ }
535
+ }
536
+ }
537
+ });
538
+ }
539
+
540
+ function updateReturnsChart() {
541
+ const ctx = document.getElementById('returnsChart');
542
+ if (!ctx) return;
543
+
544
+ if (!loadedPlan || !loadedPlan.stocks) {
545
+ if (returnsChart) {
546
+ returnsChart.destroy();
547
+ returnsChart = null;
548
+ }
549
+ return;
550
+ }
551
+
552
+ const selected = loadedPlan.stocks.filter(s => s.selected);
553
+
554
+ if (selected.length === 0) {
555
+ if (returnsChart) {
556
+ returnsChart.destroy();
557
+ returnsChart = null;
558
+ }
559
+ return;
560
+ }
561
+
562
+ // Sort by return and take top 10
563
+ const top10 = [...selected]
564
+ .sort((a, b) => b.predictedReturn - a.predictedReturn)
565
+ .slice(0, 10);
566
+
567
+ const labels = top10.map(s => s.stockId);
568
+ const data = top10.map(s => s.predictedReturn * 100);
569
+ const colors = top10.map(s => SECTOR_COLORS[s.sector] || '#64748b');
570
+
571
+ if (returnsChart) {
572
+ returnsChart.destroy();
573
+ }
574
+
575
+ returnsChart = new Chart(ctx, {
576
+ type: 'bar',
577
+ data: {
578
+ labels: labels,
579
+ datasets: [{
580
+ label: 'Predicted Return (%)',
581
+ data: data,
582
+ backgroundColor: colors,
583
+ borderRadius: 6,
584
+ borderSkipped: false
585
+ }]
586
+ },
587
+ options: {
588
+ responsive: true,
589
+ maintainAspectRatio: false,
590
+ indexAxis: 'y',
591
+ plugins: {
592
+ legend: {
593
+ display: false
594
+ },
595
+ tooltip: {
596
+ callbacks: {
597
+ label: function(context) {
598
+ const stock = top10[context.dataIndex];
599
+ return `${stock.stockName}: ${context.raw.toFixed(2)}%`;
600
+ }
601
+ }
602
+ }
603
+ },
604
+ scales: {
605
+ x: {
606
+ beginAtZero: true,
607
+ grid: {
608
+ display: true,
609
+ color: 'rgba(0,0,0,0.05)'
610
+ },
611
+ title: {
612
+ display: true,
613
+ text: 'Predicted Return (%)',
614
+ color: '#64748b'
615
+ }
616
+ },
617
+ y: {
618
+ grid: {
619
+ display: false
620
+ }
621
+ }
622
+ }
623
+ }
624
+ });
625
+ }
626
+
627
+ // =============================================================================
628
+ // Tables
629
+ // =============================================================================
630
+
631
+ function renderSelectedStocksTable() {
632
+ const tbody = $("#selectedStocksTable");
633
+
634
+ if (!loadedPlan || !loadedPlan.stocks) {
635
+ tbody.html(`
636
+ <tr>
637
+ <td colspan="5" class="text-center text-muted py-4">
638
+ <i class="fas fa-info-circle me-2"></i>No data loaded
639
+ </td>
640
+ </tr>
641
+ `);
642
+ return;
643
+ }
644
+
645
+ const selected = loadedPlan.stocks
646
+ .filter(s => s.selected)
647
+ .sort((a, b) => b.predictedReturn - a.predictedReturn);
648
+
649
+ if (selected.length === 0) {
650
+ tbody.html(`
651
+ <tr>
652
+ <td colspan="5" class="text-center text-muted py-4">
653
+ <i class="fas fa-info-circle me-2"></i>No stocks selected yet
654
+ </td>
655
+ </tr>
656
+ `);
657
+ return;
658
+ }
659
+
660
+ const weightPerStock = 100 / selected.length;
661
+
662
+ tbody.html(selected.map(stock => {
663
+ const returnClass = stock.predictedReturn >= 0 ? 'return-positive' : 'return-negative';
664
+ const returnSign = stock.predictedReturn >= 0 ? '+' : '';
665
+
666
+ return `
667
+ <tr class="stock-selected">
668
+ <td><strong>${stock.stockId}</strong></td>
669
+ <td>${stock.stockName}</td>
670
+ <td><span class="sector-badge sector-${stock.sector}">${stock.sector}</span></td>
671
+ <td class="${returnClass}">${returnSign}${(stock.predictedReturn * 100).toFixed(2)}%</td>
672
+ <td>${weightPerStock.toFixed(2)}%</td>
673
+ </tr>
674
+ `;
675
+ }).join(''));
676
+ }
677
+
678
+ function renderAllStocksTable() {
679
+ const tbody = $("#allStocksTable");
680
+ const showSelectedOnly = $("#showSelectedOnly").is(":checked");
681
+
682
+ if (!loadedPlan || !loadedPlan.stocks) {
683
+ tbody.html(`
684
+ <tr>
685
+ <td colspan="6" class="text-center text-muted py-4">
686
+ <i class="fas fa-database me-2"></i>No data loaded
687
+ </td>
688
+ </tr>
689
+ `);
690
+ return;
691
+ }
692
+
693
+ let stocks = loadedPlan.stocks;
694
+
695
+ // Filter if needed
696
+ if (showSelectedOnly) {
697
+ stocks = stocks.filter(s => s.selected);
698
+ }
699
+
700
+ // Sort
701
+ stocks = [...stocks].sort((a, b) => {
702
+ let aVal = a[sortColumn];
703
+ let bVal = b[sortColumn];
704
+
705
+ // Handle boolean (selected)
706
+ if (sortColumn === 'selected') {
707
+ aVal = a.selected ? 1 : 0;
708
+ bVal = b.selected ? 1 : 0;
709
+ }
710
+
711
+ // Handle strings
712
+ if (typeof aVal === 'string') {
713
+ aVal = aVal.toLowerCase();
714
+ bVal = bVal.toLowerCase();
715
+ }
716
+
717
+ if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
718
+ if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
719
+ return 0;
720
+ });
721
+
722
+ if (stocks.length === 0) {
723
+ tbody.html(`
724
+ <tr>
725
+ <td colspan="6" class="text-center text-muted py-4">
726
+ <i class="fas fa-filter me-2"></i>No stocks match the filter
727
+ </td>
728
+ </tr>
729
+ `);
730
+ return;
731
+ }
732
+
733
+ // Calculate weight per stock
734
+ const selectedCount = loadedPlan.stocks.filter(s => s.selected).length;
735
+ const weightPerStock = selectedCount > 0 ? (100 / selectedCount) : 0;
736
+
737
+ tbody.html(stocks.map(stock => {
738
+ const returnClass = stock.predictedReturn >= 0 ? 'return-positive' : 'return-negative';
739
+ const returnSign = stock.predictedReturn >= 0 ? '+' : '';
740
+ const rowClass = stock.selected ? 'stock-selected' : '';
741
+ const weight = stock.selected ? weightPerStock.toFixed(2) : '0.00';
742
+
743
+ return `
744
+ <tr class="${rowClass}">
745
+ <td>
746
+ <span class="badge ${stock.selected ? 'bg-success' : 'bg-secondary'}">
747
+ ${stock.selected ? 'Yes' : 'No'}
748
+ </span>
749
+ </td>
750
+ <td><strong>${stock.stockId}</strong></td>
751
+ <td>${stock.stockName}</td>
752
+ <td><span class="sector-badge sector-${stock.sector}">${stock.sector}</span></td>
753
+ <td class="${returnClass}">${returnSign}${(stock.predictedReturn * 100).toFixed(2)}%</td>
754
+ <td>${weight}%</td>
755
+ </tr>
756
+ `;
757
+ }).join(''));
758
+ }
759
+
760
+ function updateSortIndicators() {
761
+ const table = $("#allStocksTable").closest("table");
762
+ table.find("th").removeClass("sorted");
763
+ table.find("th i").removeClass("fa-sort-up fa-sort-down").addClass("fa-sort");
764
+
765
+ const th = table.find(`th[data-sort="${sortColumn}"]`);
766
+ th.addClass("sorted");
767
+ th.find("i")
768
+ .removeClass("fa-sort")
769
+ .addClass(sortDirection === 'asc' ? 'fa-sort-up' : 'fa-sort-down');
770
+ }
771
+
772
+ // =============================================================================
773
+ // Header/Footer with Nav Tabs
774
+ // =============================================================================
775
+
776
+ function replaceQuickstartSolverForgeAutoHeaderFooter() {
777
+ const solverforgeHeader = $("header#solverforge-auto-header");
778
+ if (solverforgeHeader != null) {
779
+ solverforgeHeader.css("background-color", "#ffffff");
780
+ solverforgeHeader.append(
781
+ $(`<div class="container-fluid">
782
+ <nav class="navbar sticky-top navbar-expand-lg shadow-sm mb-3" style="background-color: #ffffff;">
783
+ <a class="navbar-brand" href="https://www.solverforge.org">
784
+ <img src="/webjars/solverforge/img/solverforge-horizontal.svg" alt="SolverForge logo" width="300">
785
+ </a>
786
+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
787
+ <span class="navbar-toggler-icon"></span>
788
+ </button>
789
+ <div class="collapse navbar-collapse" id="navbarNav">
790
+ <ul class="nav nav-pills ms-4">
791
+ <li class="nav-item">
792
+ <button class="nav-link active" data-bs-toggle="pill" data-bs-target="#demo" type="button" style="color: #1f2937;">
793
+ <i class="fas fa-chart-pie me-1"></i> Demo
794
+ </button>
795
+ </li>
796
+ <li class="nav-item">
797
+ <button class="nav-link" data-bs-toggle="pill" data-bs-target="#rest" type="button" style="color: #1f2937;">
798
+ <i class="fas fa-book me-1"></i> Guide
799
+ </button>
800
+ </li>
801
+ <li class="nav-item">
802
+ <button class="nav-link" data-bs-toggle="pill" data-bs-target="#openapi" type="button" style="color: #1f2937;">
803
+ <i class="fas fa-code me-1"></i> REST API
804
+ </button>
805
+ </li>
806
+ </ul>
807
+ </div>
808
+ </nav>
809
+ </div>`)
810
+ );
811
+ }
812
+
813
+ const solverforgeFooter = $("footer#solverforge-auto-footer");
814
+ if (solverforgeFooter != null) {
815
+ solverforgeFooter.append(
816
+ $(`<footer class="bg-black text-white-50">
817
+ <div class="container">
818
+ <div class="hstack gap-3 p-4">
819
+ <div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div>
820
+ <div class="vr"></div>
821
+ <div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div>
822
+ <div class="vr"></div>
823
+ <div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div>
824
+ <div class="vr"></div>
825
+ <div class="me-auto"><a class="text-white" href="mailto:info@solverforge.org">Support</a></div>
826
+ </div>
827
+ </div>
828
+ </footer>`)
829
+ );
830
+ }
831
+ }
832
+
833
+ // =============================================================================
834
+ // Utility Functions
835
+ // =============================================================================
836
+
837
+ function copyCode(button) {
838
+ const codeBlock = $(button).closest('.code-block').find('code');
839
+ const text = codeBlock.text();
840
+
841
+ navigator.clipboard.writeText(text).then(function() {
842
+ const originalHtml = $(button).html();
843
+ $(button).html('<i class="fas fa-check"></i> Copied!');
844
+ setTimeout(function() {
845
+ $(button).html(originalHtml);
846
+ }, 2000);
847
+ });
848
+ }
849
+
850
+ // Make copyCode available globally
851
+ window.copyCode = copyCode;
static/config.js ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Advanced Configuration Module
3
+ *
4
+ * This module provides advanced configuration options for the portfolio optimization
5
+ * quickstart, including presets and configurable solver parameters.
6
+ *
7
+ * NOTE: This is an optional enhancement. The core quickstart works without this module.
8
+ * To use it, include this script after app.js and call initAdvancedConfig().
9
+ */
10
+
11
+ // =============================================================================
12
+ // Configuration Presets
13
+ // =============================================================================
14
+
15
+ const PRESETS = {
16
+ conservative: {
17
+ targetStocks: 30,
18
+ maxSector: 15,
19
+ solverTime: 60,
20
+ description: "Max diversification: 30 stocks, tight sector limits, longer solve time."
21
+ },
22
+ balanced: {
23
+ targetStocks: 20,
24
+ maxSector: 25,
25
+ solverTime: 30,
26
+ description: "Balanced settings for typical portfolio optimization."
27
+ },
28
+ aggressive: {
29
+ targetStocks: 10,
30
+ maxSector: 40,
31
+ solverTime: 30,
32
+ description: "Concentrated bets: fewer stocks, looser sector limits."
33
+ },
34
+ quick: {
35
+ targetStocks: 20,
36
+ maxSector: 25,
37
+ solverTime: 10,
38
+ description: "Fast iteration: same as balanced but 10s solve time."
39
+ }
40
+ };
41
+
42
+ // Current configuration state
43
+ let currentConfig = { ...PRESETS.balanced };
44
+
45
+ // =============================================================================
46
+ // Initialization
47
+ // =============================================================================
48
+
49
+ /**
50
+ * Initialize advanced configuration UI and event handlers.
51
+ * Call this after the DOM is ready.
52
+ */
53
+ function initAdvancedConfig() {
54
+ // Preset selector
55
+ $("#presetSelector").change(function() {
56
+ const preset = $(this).val();
57
+ if (preset !== "custom" && PRESETS[preset]) {
58
+ // Use Object.assign to mutate in place - preserves window.currentConfig reference
59
+ Object.assign(currentConfig, PRESETS[preset]);
60
+ updateConfigSliders();
61
+ updatePresetDescription(preset);
62
+ applyConfigToLoadedPlan();
63
+ }
64
+ });
65
+
66
+ // Target stocks slider
67
+ $("#targetStocksSlider").on("input", function() {
68
+ currentConfig.targetStocks = parseInt(this.value);
69
+ $("#targetStocksValue").text(this.value);
70
+ markAsCustom();
71
+ applyConfigToLoadedPlan();
72
+ });
73
+
74
+ // Max sector slider
75
+ $("#maxSectorSlider").on("input", function() {
76
+ currentConfig.maxSector = parseInt(this.value);
77
+ $("#maxSectorValue").text(this.value + "%");
78
+ markAsCustom();
79
+ applyConfigToLoadedPlan();
80
+ });
81
+
82
+ // Solver time slider
83
+ $("#solverTimeSlider").on("input", function() {
84
+ currentConfig.solverTime = parseInt(this.value);
85
+ $("#solverTimeValue").text(formatSolverTime(this.value));
86
+ markAsCustom();
87
+ applyConfigToLoadedPlan();
88
+ });
89
+ }
90
+
91
+ // =============================================================================
92
+ // Configuration UI Updates
93
+ // =============================================================================
94
+
95
+ function updateConfigSliders() {
96
+ $("#targetStocksSlider").val(currentConfig.targetStocks);
97
+ $("#targetStocksValue").text(currentConfig.targetStocks);
98
+
99
+ $("#maxSectorSlider").val(currentConfig.maxSector);
100
+ $("#maxSectorValue").text(currentConfig.maxSector + "%");
101
+
102
+ $("#solverTimeSlider").val(currentConfig.solverTime);
103
+ $("#solverTimeValue").text(formatSolverTime(currentConfig.solverTime));
104
+ }
105
+
106
+ function formatSolverTime(seconds) {
107
+ if (seconds >= 60) {
108
+ const mins = Math.floor(seconds / 60);
109
+ const secs = seconds % 60;
110
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
111
+ }
112
+ return `${seconds}s`;
113
+ }
114
+
115
+ function markAsCustom() {
116
+ // Check if current settings match any preset
117
+ for (const [name, preset] of Object.entries(PRESETS)) {
118
+ if (preset.targetStocks === currentConfig.targetStocks &&
119
+ preset.maxSector === currentConfig.maxSector &&
120
+ preset.solverTime === currentConfig.solverTime) {
121
+ $("#presetSelector").val(name);
122
+ updatePresetDescription(name);
123
+ return;
124
+ }
125
+ }
126
+ // No match - mark as custom
127
+ $("#presetSelector").val("custom");
128
+ updatePresetDescription("custom");
129
+ }
130
+
131
+ function updatePresetDescription(preset) {
132
+ const descriptions = {
133
+ conservative: PRESETS.conservative.description,
134
+ balanced: PRESETS.balanced.description,
135
+ aggressive: PRESETS.aggressive.description,
136
+ quick: PRESETS.quick.description,
137
+ custom: "Custom configuration. Adjust sliders to your needs."
138
+ };
139
+ $("#presetDescription").text(descriptions[preset] || descriptions.custom);
140
+ }
141
+
142
+ // =============================================================================
143
+ // Apply Configuration to Plan
144
+ // =============================================================================
145
+
146
+ /**
147
+ * Apply current configuration to the loaded plan.
148
+ * This updates the plan object that will be sent to the solver.
149
+ */
150
+ function applyConfigToLoadedPlan() {
151
+ // loadedPlan is defined in app.js
152
+ if (typeof loadedPlan !== 'undefined' && loadedPlan) {
153
+ loadedPlan.targetPositionCount = currentConfig.targetStocks;
154
+ loadedPlan.maxSectorPercentage = currentConfig.maxSector / 100;
155
+ loadedPlan.solverConfig = {
156
+ terminationSeconds: currentConfig.solverTime
157
+ };
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Get the current configuration.
163
+ * @returns {Object} Current configuration with targetStocks, maxSector, solverTime
164
+ */
165
+ function getCurrentConfig() {
166
+ return { ...currentConfig };
167
+ }
168
+
169
+ /**
170
+ * Get target stock count from current configuration.
171
+ * Used by app.js for KPI display.
172
+ */
173
+ function getTargetStockCount() {
174
+ return currentConfig.targetStocks;
175
+ }
176
+
177
+ // Export for use in app.js
178
+ window.PRESETS = PRESETS;
179
+ window.currentConfig = currentConfig;
180
+ window.initAdvancedConfig = initAdvancedConfig;
181
+ window.applyConfigToLoadedPlan = applyConfigToLoadedPlan;
182
+ window.getCurrentConfig = getCurrentConfig;
183
+ window.getTargetStockCount = getTargetStockCount;
static/index.html ADDED
@@ -0,0 +1,672 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta http-equiv="content-type" content="text/html; charset=UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
7
+ <title>Portfolio Optimization - SolverForge for Python</title>
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css">
9
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
10
+ <link rel="stylesheet" href="/webjars/solverforge/css/solverforge-webui.css">
11
+ <link rel="icon" href="/webjars/solverforge/img/solverforge-favicon.svg" type="image/svg+xml">
12
+ <style>
13
+ /* Solving spinner */
14
+ #solvingSpinner {
15
+ display: none;
16
+ width: 1.25rem;
17
+ height: 1.25rem;
18
+ border: 2px solid #10b981;
19
+ border-top-color: transparent;
20
+ border-radius: 50%;
21
+ animation: spin 0.75s linear infinite;
22
+ vertical-align: middle;
23
+ }
24
+ #solvingSpinner.active {
25
+ display: inline-block;
26
+ }
27
+ @keyframes spin {
28
+ to { transform: rotate(360deg); }
29
+ }
30
+
31
+ /* Sector badge colors */
32
+ .sector-badge {
33
+ font-size: 0.75rem;
34
+ padding: 4px 10px;
35
+ border-radius: 12px;
36
+ font-weight: 500;
37
+ }
38
+ .sector-Technology { background: #3b82f6; color: white; }
39
+ .sector-Healthcare { background: #10b981; color: white; }
40
+ .sector-Finance { background: #eab308; color: #1a1a1a; }
41
+ .sector-Energy { background: #ef4444; color: white; }
42
+ .sector-Consumer { background: #a855f7; color: white; }
43
+
44
+ /* KPI Cards */
45
+ .kpi-card {
46
+ background: white;
47
+ border-radius: 12px;
48
+ padding: 1.5rem;
49
+ text-align: center;
50
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
51
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
52
+ }
53
+ .kpi-card:hover {
54
+ transform: translateY(-2px);
55
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
56
+ }
57
+ .kpi-value {
58
+ font-size: 2.5rem;
59
+ font-weight: 700;
60
+ color: #10b981;
61
+ line-height: 1.2;
62
+ transition: all 0.3s ease;
63
+ }
64
+ .kpi-value.updating {
65
+ opacity: 0.5;
66
+ }
67
+ .kpi-value.positive { color: #10b981; }
68
+ .kpi-value.negative { color: #ef4444; }
69
+ .kpi-label {
70
+ font-size: 0.85rem;
71
+ color: #64748b;
72
+ text-transform: uppercase;
73
+ letter-spacing: 0.05em;
74
+ margin-top: 0.5rem;
75
+ }
76
+ .kpi-icon {
77
+ font-size: 1.5rem;
78
+ color: #94a3b8;
79
+ margin-bottom: 0.5rem;
80
+ }
81
+
82
+ /* Chart containers */
83
+ .chart-card {
84
+ background: white;
85
+ border-radius: 12px;
86
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
87
+ height: 100%;
88
+ }
89
+ .chart-card-header {
90
+ padding: 1rem 1.25rem;
91
+ border-bottom: 1px solid #e2e8f0;
92
+ font-weight: 600;
93
+ color: #1e293b;
94
+ }
95
+ .chart-card-body {
96
+ padding: 1rem;
97
+ }
98
+ .chart-container {
99
+ position: relative;
100
+ height: 280px;
101
+ }
102
+
103
+ /* Stock table */
104
+ .stock-table {
105
+ font-size: 0.9rem;
106
+ }
107
+ .stock-table th {
108
+ background: #f8fafc;
109
+ font-weight: 600;
110
+ text-transform: uppercase;
111
+ font-size: 0.75rem;
112
+ letter-spacing: 0.05em;
113
+ color: #64748b;
114
+ border-bottom: 2px solid #e2e8f0;
115
+ cursor: pointer;
116
+ user-select: none;
117
+ }
118
+ .stock-table th:hover {
119
+ background: #f1f5f9;
120
+ }
121
+ .stock-table th i {
122
+ margin-left: 4px;
123
+ opacity: 0.5;
124
+ }
125
+ .stock-table th.sorted i {
126
+ opacity: 1;
127
+ color: #10b981;
128
+ }
129
+ .stock-selected {
130
+ background: #f0fdf4 !important;
131
+ }
132
+ .stock-table tr {
133
+ transition: background-color 0.15s ease;
134
+ }
135
+ .stock-table tbody tr:hover {
136
+ background: #f8fafc;
137
+ }
138
+ .return-positive { color: #10b981; font-weight: 600; }
139
+ .return-negative { color: #ef4444; font-weight: 600; }
140
+
141
+ /* Score display */
142
+ .score-badge {
143
+ display: inline-block;
144
+ padding: 6px 12px;
145
+ border-radius: 8px;
146
+ font-weight: 600;
147
+ font-size: 0.9rem;
148
+ }
149
+ .score-hard {
150
+ background: #fef2f2;
151
+ color: #dc2626;
152
+ border: 1px solid #fecaca;
153
+ }
154
+ .score-soft {
155
+ background: #f0fdf4;
156
+ color: #16a34a;
157
+ border: 1px solid #bbf7d0;
158
+ }
159
+ .score-feasible {
160
+ background: #f0fdf4;
161
+ color: #16a34a;
162
+ }
163
+ .score-infeasible {
164
+ background: #fef2f2;
165
+ color: #dc2626;
166
+ }
167
+
168
+ /* Data selector dropdown */
169
+ .data-selector {
170
+ background: #10b981;
171
+ color: white;
172
+ border: none;
173
+ border-radius: 1.5rem;
174
+ padding: 0.5rem 1rem;
175
+ font-weight: 500;
176
+ }
177
+ .data-selector:focus {
178
+ box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.3);
179
+ }
180
+
181
+ /* Notification panel */
182
+ #notificationPanel {
183
+ z-index: 1050;
184
+ }
185
+
186
+ /* Copy button for code blocks */
187
+ .copy-btn {
188
+ position: absolute;
189
+ top: 0.5rem;
190
+ right: 0.5rem;
191
+ opacity: 0;
192
+ transition: opacity 0.2s;
193
+ }
194
+ .code-block:hover .copy-btn {
195
+ opacity: 1;
196
+ }
197
+ .code-block {
198
+ position: relative;
199
+ }
200
+
201
+ /* Progress animation during solving */
202
+ @keyframes pulse {
203
+ 0%, 100% { opacity: 1; }
204
+ 50% { opacity: 0.6; }
205
+ }
206
+ .solving-pulse {
207
+ animation: pulse 1.5s ease-in-out infinite;
208
+ }
209
+
210
+ /* ==========================================================
211
+ ADVANCED SETTINGS STYLES (OPTIONAL)
212
+ Remove this section for a simpler quickstart.
213
+ ========================================================== */
214
+ .settings-card {
215
+ background: white;
216
+ border-radius: 12px;
217
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
218
+ }
219
+ .settings-card .card-body {
220
+ padding: 1.25rem;
221
+ }
222
+ .form-range::-webkit-slider-thumb {
223
+ background: #10b981;
224
+ }
225
+ .form-range::-moz-range-thumb {
226
+ background: #10b981;
227
+ }
228
+ .preset-badge {
229
+ font-size: 0.7rem;
230
+ padding: 2px 6px;
231
+ border-radius: 4px;
232
+ background: #f1f5f9;
233
+ color: #64748b;
234
+ margin-left: 0.5rem;
235
+ }
236
+ .config-value {
237
+ font-weight: 600;
238
+ color: #10b981;
239
+ }
240
+ /* END ADVANCED SETTINGS STYLES */
241
+ </style>
242
+ </head>
243
+ <body>
244
+
245
+ <header id="solverforge-auto-header">
246
+ <!-- Filled by solverforge-webui.js -->
247
+ </header>
248
+
249
+ <div class="tab-content">
250
+ <!-- Tab 1: Demo UI -->
251
+ <div id="demo" class="tab-pane fade show active container-fluid">
252
+ <div class="sticky-top d-flex justify-content-center align-items-center">
253
+ <div id="notificationPanel" style="position: absolute; top: .5rem;"></div>
254
+ </div>
255
+
256
+ <h1>Portfolio Optimization</h1>
257
+ <p>Select optimal stocks to maximize expected returns while meeting sector exposure constraints.</p>
258
+
259
+ <!-- Control Bar -->
260
+ <div class="container-fluid mb-3">
261
+ <div class="row justify-content-between align-items-center">
262
+ <div class="col-auto">
263
+ <ul class="nav nav-pills" role="tablist">
264
+ <li class="nav-item" role="presentation">
265
+ <button class="nav-link active" id="portfolioTab" data-bs-toggle="tab" data-bs-target="#portfolioPanel"
266
+ type="button" role="tab" aria-controls="portfolioPanel" aria-selected="true">
267
+ <i class="fas fa-chart-pie me-1"></i> Portfolio
268
+ </button>
269
+ </li>
270
+ <li class="nav-item" role="presentation">
271
+ <button class="nav-link" id="stocksTab" data-bs-toggle="tab" data-bs-target="#stocksPanel"
272
+ type="button" role="tab" aria-controls="stocksPanel" aria-selected="false">
273
+ <i class="fas fa-list me-1"></i> All Stocks
274
+ </button>
275
+ </li>
276
+ </ul>
277
+ </div>
278
+ <div class="col-auto">
279
+ <select id="dataSelector" class="data-selector me-2">
280
+ <option value="SMALL">Small (25 stocks)</option>
281
+ <option value="LARGE">Large (51 stocks)</option>
282
+ </select>
283
+ <!-- ============================================================
284
+ ADVANCED SETTINGS BUTTON (OPTIONAL)
285
+ Remove this button and the panel below for a simpler quickstart.
286
+ Also remove config.js script at the bottom of the file.
287
+ ============================================================ -->
288
+ <button class="btn btn-outline-secondary btn-sm me-2" type="button"
289
+ data-bs-toggle="collapse" data-bs-target="#advancedSettings"
290
+ aria-expanded="false" aria-controls="advancedSettings"
291
+ id="advancedSettingsBtn">
292
+ <i class="fas fa-cog"></i> Advanced
293
+ </button>
294
+ <!-- END ADVANCED SETTINGS BUTTON -->
295
+ <button id="solveButton" type="button" class="btn btn-success">
296
+ <i class="fas fa-play"></i> Solve
297
+ </button>
298
+ <button id="stopSolvingButton" type="button" class="btn btn-danger" style="display: none;">
299
+ <i class="fas fa-stop"></i> Stop
300
+ </button>
301
+ <span id="solvingSpinner" class="ms-2"></span>
302
+ <span id="score" class="ms-2 fw-bold">Score: ?</span>
303
+ <button id="analyzeButton" type="button" class="btn btn-secondary ms-2" title="Analyze constraints">
304
+ <i class="fas fa-question"></i>
305
+ </button>
306
+ </div>
307
+ </div>
308
+
309
+ <!-- ============================================================
310
+ ADVANCED SETTINGS PANEL (OPTIONAL)
311
+ This entire panel can be removed for a simpler quickstart.
312
+ If removed, also remove:
313
+ - The "Advanced" button above
314
+ - config.js script at the bottom of the file
315
+ ============================================================ -->
316
+ <div class="collapse mt-3" id="advancedSettings">
317
+ <div class="settings-card">
318
+ <div class="card-body">
319
+ <div class="row g-4">
320
+ <!-- Preset Selector -->
321
+ <div class="col-md-3">
322
+ <label class="form-label fw-bold">
323
+ <i class="fas fa-sliders-h me-1 text-muted"></i>Preset
324
+ </label>
325
+ <select id="presetSelector" class="form-select">
326
+ <option value="balanced" selected>Balanced (Default)</option>
327
+ <option value="conservative">Conservative</option>
328
+ <option value="aggressive">Aggressive</option>
329
+ <option value="quick">Quick Test</option>
330
+ <option value="custom">Custom</option>
331
+ </select>
332
+ </div>
333
+
334
+ <!-- Target Stocks Slider -->
335
+ <div class="col-md-3">
336
+ <label class="form-label">
337
+ Target Stocks: <span id="targetStocksValue" class="config-value">20</span>
338
+ </label>
339
+ <input type="range" class="form-range" id="targetStocksSlider"
340
+ min="5" max="50" value="20">
341
+ <div class="d-flex justify-content-between text-muted" style="font-size: 0.7rem;">
342
+ <span>5</span>
343
+ <span>50</span>
344
+ </div>
345
+ </div>
346
+
347
+ <!-- Max Sector Slider -->
348
+ <div class="col-md-3">
349
+ <label class="form-label">
350
+ Max Sector: <span id="maxSectorValue" class="config-value">25%</span>
351
+ </label>
352
+ <input type="range" class="form-range" id="maxSectorSlider"
353
+ min="10" max="50" value="25">
354
+ <div class="d-flex justify-content-between text-muted" style="font-size: 0.7rem;">
355
+ <span>10%</span>
356
+ <span>50%</span>
357
+ </div>
358
+ </div>
359
+
360
+ <!-- Solver Time Slider -->
361
+ <div class="col-md-3">
362
+ <label class="form-label">
363
+ Solver Time: <span id="solverTimeValue" class="config-value">30s</span>
364
+ </label>
365
+ <input type="range" class="form-range" id="solverTimeSlider"
366
+ min="10" max="300" step="10" value="30">
367
+ <div class="d-flex justify-content-between text-muted" style="font-size: 0.7rem;">
368
+ <span>10s</span>
369
+ <span>5min</span>
370
+ </div>
371
+ </div>
372
+ </div>
373
+
374
+ <!-- Preset Description -->
375
+ <div class="mt-3 text-muted" style="font-size: 0.85rem;">
376
+ <i class="fas fa-info-circle me-1"></i>
377
+ <span id="presetDescription">Balanced settings for typical portfolio optimization.</span>
378
+ </div>
379
+ </div>
380
+ </div>
381
+ </div>
382
+ <!-- END ADVANCED SETTINGS PANEL -->
383
+ </div>
384
+
385
+ <!-- Inner tab content -->
386
+ <div class="tab-content">
387
+ <!-- Portfolio View -->
388
+ <div class="tab-pane fade show active" id="portfolioPanel" role="tabpanel" aria-labelledby="portfolioTab">
389
+ <!-- KPI Row 1: Core Metrics -->
390
+ <div class="row mb-3 g-3">
391
+ <div class="col-md-3 col-sm-6">
392
+ <div class="kpi-card">
393
+ <div class="kpi-icon"><i class="fas fa-check-circle"></i></div>
394
+ <div class="kpi-value" id="selectedCount">0/20</div>
395
+ <div class="kpi-label">Selected Stocks</div>
396
+ </div>
397
+ </div>
398
+ <div class="col-md-3 col-sm-6">
399
+ <div class="kpi-card">
400
+ <div class="kpi-icon"><i class="fas fa-chart-line"></i></div>
401
+ <div class="kpi-value positive" id="expectedReturn">0.00%</div>
402
+ <div class="kpi-label">Expected Return</div>
403
+ </div>
404
+ </div>
405
+ <div class="col-md-3 col-sm-6">
406
+ <div class="kpi-card">
407
+ <div class="kpi-icon"><i class="fas fa-layer-group"></i></div>
408
+ <div class="kpi-value" id="sectorCount">0</div>
409
+ <div class="kpi-label">Sectors</div>
410
+ </div>
411
+ </div>
412
+ <div class="col-md-3 col-sm-6">
413
+ <div class="kpi-card" id="diversificationTooltip" data-bs-toggle="tooltip" title="Higher = more diversified. Based on Herfindahl-Hirschman Index (HHI).">
414
+ <div class="kpi-icon"><i class="fas fa-shuffle"></i></div>
415
+ <div class="kpi-value" id="diversificationScore">0%</div>
416
+ <div class="kpi-label">Diversification <i class="fas fa-info-circle text-muted" style="font-size: 0.7rem;"></i></div>
417
+ </div>
418
+ </div>
419
+ </div>
420
+
421
+ <!-- KPI Row 2: Risk Metrics -->
422
+ <div class="row mb-4 g-3">
423
+ <div class="col-md-3 col-sm-6">
424
+ <div class="kpi-card" data-bs-toggle="tooltip" title="Highest single sector weight. Lower is more diversified.">
425
+ <div class="kpi-icon"><i class="fas fa-chart-pie"></i></div>
426
+ <div class="kpi-value" id="maxSectorExposure">0%</div>
427
+ <div class="kpi-label">Max Sector <i class="fas fa-info-circle text-muted" style="font-size: 0.7rem;"></i></div>
428
+ </div>
429
+ </div>
430
+ <div class="col-md-3 col-sm-6">
431
+ <div class="kpi-card" data-bs-toggle="tooltip" title="Standard deviation of predicted returns. Lower = less risk.">
432
+ <div class="kpi-icon"><i class="fas fa-wave-square"></i></div>
433
+ <div class="kpi-value" id="returnVolatility">0.00%</div>
434
+ <div class="kpi-label">Volatility <i class="fas fa-info-circle text-muted" style="font-size: 0.7rem;"></i></div>
435
+ </div>
436
+ </div>
437
+ <div class="col-md-3 col-sm-6">
438
+ <div class="kpi-card" data-bs-toggle="tooltip" title="Return / Volatility ratio. Higher = better risk-adjusted return.">
439
+ <div class="kpi-icon"><i class="fas fa-scale-balanced"></i></div>
440
+ <div class="kpi-value" id="sharpeProxy">0.00</div>
441
+ <div class="kpi-label">Sharpe Proxy <i class="fas fa-info-circle text-muted" style="font-size: 0.7rem;"></i></div>
442
+ </div>
443
+ </div>
444
+ <div class="col-md-3 col-sm-6">
445
+ <div class="kpi-card" data-bs-toggle="tooltip" title="Herfindahl-Hirschman Index. Lower = more diversified. <0.15 is well-diversified.">
446
+ <div class="kpi-icon"><i class="fas fa-bullseye"></i></div>
447
+ <div class="kpi-value" id="herfindahlIndex">0.000</div>
448
+ <div class="kpi-label">HHI <i class="fas fa-info-circle text-muted" style="font-size: 0.7rem;"></i></div>
449
+ </div>
450
+ </div>
451
+ </div>
452
+
453
+ <!-- Charts Row -->
454
+ <div class="row mb-4 g-3">
455
+ <div class="col-md-6">
456
+ <div class="chart-card">
457
+ <div class="chart-card-header">
458
+ <i class="fas fa-chart-pie me-2 text-muted"></i>Sector Allocation
459
+ </div>
460
+ <div class="chart-card-body">
461
+ <div class="chart-container">
462
+ <canvas id="sectorChart"></canvas>
463
+ </div>
464
+ </div>
465
+ </div>
466
+ </div>
467
+ <div class="col-md-6">
468
+ <div class="chart-card">
469
+ <div class="chart-card-header">
470
+ <i class="fas fa-chart-bar me-2 text-muted"></i>Top Returns (Selected)
471
+ </div>
472
+ <div class="chart-card-body">
473
+ <div class="chart-container">
474
+ <canvas id="returnsChart"></canvas>
475
+ </div>
476
+ </div>
477
+ </div>
478
+ </div>
479
+ </div>
480
+
481
+ <!-- Selected Stocks Summary -->
482
+ <div class="row">
483
+ <div class="col-12">
484
+ <div class="chart-card">
485
+ <div class="chart-card-header d-flex justify-content-between align-items-center">
486
+ <span><i class="fas fa-star me-2 text-warning"></i>Selected Stocks</span>
487
+ <span class="badge bg-success" id="selectedBadge">0 selected</span>
488
+ </div>
489
+ <div class="chart-card-body p-0">
490
+ <div class="table-responsive">
491
+ <table class="table table-hover stock-table mb-0">
492
+ <thead>
493
+ <tr>
494
+ <th>Ticker</th>
495
+ <th>Company</th>
496
+ <th>Sector</th>
497
+ <th>Predicted Return</th>
498
+ <th>Weight</th>
499
+ </tr>
500
+ </thead>
501
+ <tbody id="selectedStocksTable">
502
+ <tr>
503
+ <td colspan="5" class="text-center text-muted py-4">
504
+ <i class="fas fa-info-circle me-2"></i>Click "Solve" to optimize your portfolio
505
+ </td>
506
+ </tr>
507
+ </tbody>
508
+ </table>
509
+ </div>
510
+ </div>
511
+ </div>
512
+ </div>
513
+ </div>
514
+ </div>
515
+
516
+ <!-- All Stocks View -->
517
+ <div class="tab-pane fade" id="stocksPanel" role="tabpanel" aria-labelledby="stocksTab">
518
+ <div class="chart-card">
519
+ <div class="chart-card-header d-flex justify-content-between align-items-center">
520
+ <span><i class="fas fa-list me-2 text-muted"></i>All Available Stocks</span>
521
+ <div class="d-flex align-items-center gap-3">
522
+ <div class="form-check form-switch mb-0">
523
+ <input class="form-check-input" type="checkbox" id="showSelectedOnly">
524
+ <label class="form-check-label" for="showSelectedOnly">Selected only</label>
525
+ </div>
526
+ <span class="badge bg-secondary" id="stockCountBadge">0 stocks</span>
527
+ </div>
528
+ </div>
529
+ <div class="chart-card-body p-0">
530
+ <div class="table-responsive" style="max-height: 70vh; overflow-y: auto;">
531
+ <table class="table table-hover stock-table mb-0">
532
+ <thead class="sticky-top">
533
+ <tr>
534
+ <th data-sort="selected">Selected <i class="fas fa-sort"></i></th>
535
+ <th data-sort="stockId">Ticker <i class="fas fa-sort"></i></th>
536
+ <th data-sort="stockName">Company <i class="fas fa-sort"></i></th>
537
+ <th data-sort="sector">Sector <i class="fas fa-sort"></i></th>
538
+ <th data-sort="predictedReturn">Predicted Return <i class="fas fa-sort"></i></th>
539
+ <th>Weight</th>
540
+ </tr>
541
+ </thead>
542
+ <tbody id="allStocksTable">
543
+ <tr>
544
+ <td colspan="6" class="text-center text-muted py-4">
545
+ <i class="fas fa-database me-2"></i>Loading stock data...
546
+ </td>
547
+ </tr>
548
+ </tbody>
549
+ </table>
550
+ </div>
551
+ </div>
552
+ </div>
553
+ </div>
554
+ </div>
555
+ </div>
556
+
557
+ <!-- Tab 2: REST API Guide -->
558
+ <div id="rest" class="tab-pane fade container-fluid">
559
+ <h2>REST API Guide</h2>
560
+ <p class="text-muted">Use these endpoints to integrate portfolio optimization into your application.</p>
561
+
562
+ <h3 class="mt-4">1. Load demo data</h3>
563
+ <p>Retrieve a pre-configured portfolio with sample stocks.</p>
564
+ <div class="code-block">
565
+ <button class="btn btn-outline-dark btn-sm copy-btn" onclick="copyCode(this)">
566
+ <i class="fas fa-copy"></i> Copy
567
+ </button>
568
+ <pre><code>curl -X GET http://localhost:8080/demo-data/SMALL</code></pre>
569
+ </div>
570
+
571
+ <h3 class="mt-4">2. Submit portfolio for optimization</h3>
572
+ <p>Start the solver to find the optimal stock selection.</p>
573
+ <div class="code-block">
574
+ <button class="btn btn-outline-dark btn-sm copy-btn" onclick="copyCode(this)">
575
+ <i class="fas fa-copy"></i> Copy
576
+ </button>
577
+ <pre><code>curl -X POST -H "Content-Type: application/json" \
578
+ -d @portfolio.json \
579
+ http://localhost:8080/portfolios</code></pre>
580
+ </div>
581
+
582
+ <h3 class="mt-4">3. Get current solution</h3>
583
+ <p>Poll for the latest solution while the solver is running.</p>
584
+ <div class="code-block">
585
+ <button class="btn btn-outline-dark btn-sm copy-btn" onclick="copyCode(this)">
586
+ <i class="fas fa-copy"></i> Copy
587
+ </button>
588
+ <pre><code>curl -X GET http://localhost:8080/portfolios/{jobId}</code></pre>
589
+ </div>
590
+
591
+ <h3 class="mt-4">4. Stop solving</h3>
592
+ <p>Terminate the solver and retrieve the best solution found.</p>
593
+ <div class="code-block">
594
+ <button class="btn btn-outline-dark btn-sm copy-btn" onclick="copyCode(this)">
595
+ <i class="fas fa-copy"></i> Copy
596
+ </button>
597
+ <pre><code>curl -X DELETE http://localhost:8080/portfolios/{jobId}</code></pre>
598
+ </div>
599
+
600
+ <h3 class="mt-4">5. Analyze constraints</h3>
601
+ <p>Get a detailed breakdown of constraint scores for a given solution.</p>
602
+ <div class="code-block">
603
+ <button class="btn btn-outline-dark btn-sm copy-btn" onclick="copyCode(this)">
604
+ <i class="fas fa-copy"></i> Copy
605
+ </button>
606
+ <pre><code>curl -X PUT -H "Content-Type: application/json" \
607
+ -d @portfolio.json \
608
+ http://localhost:8080/portfolios/analyze</code></pre>
609
+ </div>
610
+ </div>
611
+
612
+ <!-- Tab 3: OpenAPI Reference -->
613
+ <div id="openapi" class="tab-pane fade container-fluid">
614
+ <h2>OpenAPI Reference</h2>
615
+ <p class="text-muted mb-4">Interactive API documentation powered by Swagger UI.</p>
616
+ <div class="ratio ratio-1x1">
617
+ <iframe src="/q/swagger-ui" title="OpenAPI Documentation"></iframe>
618
+ </div>
619
+ </div>
620
+ </div>
621
+
622
+ <!-- Score Analysis Modal -->
623
+ <div class="modal fade" id="weightModal" tabindex="-1" aria-labelledby="weightModalTitle" aria-hidden="true">
624
+ <div class="modal-dialog modal-lg modal-dialog-scrollable">
625
+ <div class="modal-content">
626
+ <div class="modal-header">
627
+ <h5 class="modal-title" id="weightModalTitle">Constraint Analysis</h5>
628
+ <span id="weightModalLabel" class="ms-2 badge bg-secondary"></span>
629
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
630
+ </div>
631
+ <div class="modal-body">
632
+ <table class="table table-striped" id="weightModalContent">
633
+ <thead>
634
+ <tr>
635
+ <th></th>
636
+ <th>Constraint</th>
637
+ <th>Type</th>
638
+ <th>Weight</th>
639
+ <th>Matches</th>
640
+ <th>Score</th>
641
+ </tr>
642
+ </thead>
643
+ <tbody>
644
+ <!-- Populated by JavaScript -->
645
+ </tbody>
646
+ </table>
647
+ </div>
648
+ <div class="modal-footer">
649
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
650
+ </div>
651
+ </div>
652
+ </div>
653
+ </div>
654
+
655
+ <footer id="solverforge-auto-footer">
656
+ <!-- Filled by solverforge-webui.js -->
657
+ </footer>
658
+
659
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
660
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.bundle.min.js"></script>
661
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
662
+ <script src="/webjars/solverforge/js/solverforge-webui.js"></script>
663
+ <script src="app.js"></script>
664
+ <!-- ============================================================
665
+ ADVANCED CONFIGURATION SCRIPT (OPTIONAL)
666
+ Remove this script for a simpler quickstart.
667
+ Also remove the "Advanced" button and panel in the HTML above.
668
+ ============================================================ -->
669
+ <script src="config.js"></script>
670
+ <!-- END ADVANCED CONFIGURATION SCRIPT -->
671
+ </body>
672
+ </html>
static/webjars/solverforge/css/solverforge-webui.css ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ /* Keep in sync with .navbar height on a large screen. */
3
+ --ts-navbar-height: 109px;
4
+
5
+ --ts-green-1-rgb: #10b981;
6
+ --ts-green-2-rgb: #059669;
7
+ --ts-violet-1-rgb: #3E00FF;
8
+ --ts-violet-2-rgb: #3423A6;
9
+ --ts-violet-3-rgb: #2E1760;
10
+ --ts-violet-4-rgb: #200F4F;
11
+ --ts-violet-5-rgb: #000000; /* TODO FIXME */
12
+ --ts-violet-dark-1-rgb: #b6adfd;
13
+ --ts-violet-dark-2-rgb: #c1bbfd;
14
+ --ts-gray-rgb: #666666;
15
+ --ts-white-rgb: #FFFFFF;
16
+ --ts-light-rgb: #F2F2F2;
17
+ --ts-gray-border: #c5c5c5;
18
+
19
+ --tf-light-rgb-transparent: rgb(242,242,242,0.5); /* #F2F2F2 = rgb(242,242,242) */
20
+ --bs-body-bg: var(--ts-light-rgb); /* link to html bg */
21
+ --bs-link-color: var(--ts-violet-1-rgb);
22
+ --bs-link-hover-color: var(--ts-violet-2-rgb);
23
+
24
+ --bs-navbar-color: var(--ts-white-rgb);
25
+ --bs-navbar-hover-color: var(--ts-white-rgb);
26
+ --bs-nav-link-font-size: 18px;
27
+ --bs-nav-link-font-weight: 400;
28
+ --bs-nav-link-color: var(--ts-white-rgb);
29
+ --ts-nav-link-hover-border-color: var(--ts-violet-1-rgb);
30
+ }
31
+ .btn {
32
+ --bs-btn-border-radius: 1.5rem;
33
+ }
34
+ .btn-primary {
35
+ --bs-btn-bg: var(--ts-violet-1-rgb);
36
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
37
+ --bs-btn-hover-bg: var(--ts-violet-2-rgb);
38
+ --bs-btn-hover-border-color: var(--ts-violet-2-rgb);
39
+ --bs-btn-active-bg: var(--ts-violet-2-rgb);
40
+ --bs-btn-active-border-bg: var(--ts-violet-2-rgb);
41
+ --bs-btn-disabled-bg: var(--ts-violet-1-rgb);
42
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
43
+ }
44
+ .btn-outline-primary {
45
+ --bs-btn-color: var(--ts-violet-1-rgb);
46
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
47
+ --bs-btn-hover-bg: var(--ts-violet-1-rgb);
48
+ --bs-btn-hover-border-color: var(--ts-violet-1-rgb);
49
+ --bs-btn-active-bg: var(--ts-violet-1-rgb);
50
+ --bs-btn-active-border-color: var(--ts-violet-1-rgb);
51
+ --bs-btn-disabled-color: var(--ts-violet-1-rgb);
52
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
53
+ }
54
+ .navbar-dark {
55
+ --bs-link-color: var(--ts-violet-dark-1-rgb);
56
+ --bs-link-hover-color: var(--ts-violet-dark-2-rgb);
57
+ --bs-navbar-color: var(--ts-white-rgb);
58
+ --bs-navbar-hover-color: var(--ts-white-rgb);
59
+ }
60
+ .nav-pills {
61
+ --bs-nav-pills-link-active-bg: var(--ts-green-1-rgb);
62
+ }
63
+ .nav-pills .nav-link:hover {
64
+ color: var(--ts-green-1-rgb);
65
+ }
66
+ .nav-pills .nav-link.active:hover {
67
+ color: var(--ts-white-rgb);
68
+ }
static/webjars/solverforge/img/solverforge-favicon.svg ADDED
static/webjars/solverforge/img/solverforge-horizontal-white.svg ADDED
static/webjars/solverforge/img/solverforge-horizontal.svg ADDED
static/webjars/solverforge/img/solverforge-logo-stacked.svg ADDED
static/webjars/solverforge/js/solverforge-webui.js ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function replaceSolverForgeAutoHeaderFooter() {
2
+ const solverforgeHeader = $("header#solverforge-auto-header");
3
+ if (solverforgeHeader != null) {
4
+ solverforgeHeader.addClass("bg-black")
5
+ solverforgeHeader.append(
6
+ $(`<div class="container-fluid">
7
+ <nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3">
8
+ <a class="navbar-brand" href="https://www.solverforge.org">
9
+ <img src="/solverforge/img/solverforge-horizontal-white.svg" alt="SolverForge logo" width="200">
10
+ </a>
11
+ </nav>
12
+ </div>`));
13
+ }
14
+ const solverforgeFooter = $("footer#solverforge-auto-footer");
15
+ if (solverforgeFooter != null) {
16
+ solverforgeFooter.append(
17
+ $(`<footer class="bg-black text-white-50">
18
+ <div class="container">
19
+ <div class="hstack gap-3 p-4">
20
+ <div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div>
21
+ <div class="vr"></div>
22
+ <div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div>
23
+ <div class="vr"></div>
24
+ <div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div>
25
+ <div class="vr"></div>
26
+ <div class="me-auto"><a class="text-white" href="mailto:info@solverforge.org">Support</a></div>
27
+ </div>
28
+ </div>
29
+ <div id="applicationInfo" class="container text-center"></div>
30
+ </footer>`));
31
+
32
+ applicationInfo();
33
+ }
34
+
35
+ }
36
+
37
+ function showSimpleError(title) {
38
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
39
+ .append($(`<div class="toast-header bg-danger">
40
+ <strong class="me-auto text-dark">Error</strong>
41
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
42
+ </div>`))
43
+ .append($(`<div class="toast-body"/>`)
44
+ .append($(`<p/>`).text(title))
45
+ );
46
+ $("#notificationPanel").append(notification);
47
+ notification.toast({delay: 30000});
48
+ notification.toast('show');
49
+ }
50
+
51
+ function showError(title, xhr) {
52
+ var serverErrorMessage = !xhr.responseJSON ? `${xhr.status}: ${xhr.statusText}` : xhr.responseJSON.message;
53
+ var serverErrorCode = !xhr.responseJSON ? `unknown` : xhr.responseJSON.code;
54
+ var serverErrorId = !xhr.responseJSON ? `----` : xhr.responseJSON.id;
55
+ var serverErrorDetails = !xhr.responseJSON ? `no details provided` : xhr.responseJSON.details;
56
+
57
+ if (xhr.responseJSON && !serverErrorMessage) {
58
+ serverErrorMessage = JSON.stringify(xhr.responseJSON);
59
+ serverErrorCode = xhr.statusText + '(' + xhr.status + ')';
60
+ serverErrorId = `----`;
61
+ }
62
+
63
+ console.error(title + "\n" + serverErrorMessage + " : " + serverErrorDetails);
64
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
65
+ .append($(`<div class="toast-header bg-danger">
66
+ <strong class="me-auto text-dark">Error</strong>
67
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
68
+ </div>`))
69
+ .append($(`<div class="toast-body"/>`)
70
+ .append($(`<p/>`).text(title))
71
+ .append($(`<pre/>`)
72
+ .append($(`<code/>`).text(serverErrorMessage + "\n\nCode: " + serverErrorCode + "\nError id: " + serverErrorId))
73
+ )
74
+ );
75
+ $("#notificationPanel").append(notification);
76
+ notification.toast({delay: 30000});
77
+ notification.toast('show');
78
+ }
79
+
80
+ // ****************************************************************************
81
+ // Application info
82
+ // ****************************************************************************
83
+
84
+ function applicationInfo() {
85
+ $.getJSON("info", function (info) {
86
+ $("#applicationInfo").append("<small>" + info.application + " (version: " + info.version + ", built at: " + info.built + ")</small>");
87
+ }).fail(function (xhr, ajaxOptions, thrownError) {
88
+ console.warn("Unable to collect application information");
89
+ });
90
+ }
91
+
92
+ // ****************************************************************************
93
+ // TangoColorFactory
94
+ // ****************************************************************************
95
+
96
+ const SEQUENCE_1 = [0x8AE234, 0xFCE94F, 0x729FCF, 0xE9B96E, 0xAD7FA8];
97
+ const SEQUENCE_2 = [0x73D216, 0xEDD400, 0x3465A4, 0xC17D11, 0x75507B];
98
+
99
+ var colorMap = new Map;
100
+ var nextColorCount = 0;
101
+
102
+ function pickColor(object) {
103
+ let color = colorMap[object];
104
+ if (color !== undefined) {
105
+ return color;
106
+ }
107
+ color = nextColor();
108
+ colorMap[object] = color;
109
+ return color;
110
+ }
111
+
112
+ function nextColor() {
113
+ let color;
114
+ let colorIndex = nextColorCount % SEQUENCE_1.length;
115
+ let shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length);
116
+ if (shadeIndex === 0) {
117
+ color = SEQUENCE_1[colorIndex];
118
+ } else if (shadeIndex === 1) {
119
+ color = SEQUENCE_2[colorIndex];
120
+ } else {
121
+ shadeIndex -= 3;
122
+ let floorColor = SEQUENCE_2[colorIndex];
123
+ let ceilColor = SEQUENCE_1[colorIndex];
124
+ let base = Math.floor((shadeIndex / 2) + 1);
125
+ let divisor = 2;
126
+ while (base >= divisor) {
127
+ divisor *= 2;
128
+ }
129
+ base = (base * 2) - divisor + 1;
130
+ let shadePercentage = base / divisor;
131
+ color = buildPercentageColor(floorColor, ceilColor, shadePercentage);
132
+ }
133
+ nextColorCount++;
134
+ return "#" + color.toString(16);
135
+ }
136
+
137
+ function buildPercentageColor(floorColor, ceilColor, shadePercentage) {
138
+ let red = (floorColor & 0xFF0000) + Math.floor(shadePercentage * ((ceilColor & 0xFF0000) - (floorColor & 0xFF0000))) & 0xFF0000;
139
+ let green = (floorColor & 0x00FF00) + Math.floor(shadePercentage * ((ceilColor & 0x00FF00) - (floorColor & 0x00FF00))) & 0x00FF00;
140
+ let blue = (floorColor & 0x0000FF) + Math.floor(shadePercentage * ((ceilColor & 0x0000FF) - (floorColor & 0x0000FF))) & 0x0000FF;
141
+ return red | green | blue;
142
+ }
tests/test_business_metrics.py ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for business metrics in the Portfolio Optimization quickstart.
3
+
4
+ These tests verify the financial KPIs calculated by the domain model:
5
+ - Herfindahl-Hirschman Index (HHI) for concentration
6
+ - Diversification score (1 - HHI)
7
+ - Max sector exposure
8
+ - Expected return
9
+ - Return volatility
10
+ - Sharpe proxy (return / volatility)
11
+
12
+ These metrics provide business insight beyond the solver score.
13
+ """
14
+ import pytest
15
+ import math
16
+
17
+ from portfolio_optimization.domain import (
18
+ StockSelection,
19
+ PortfolioOptimizationPlan,
20
+ PortfolioConfig,
21
+ PortfolioMetricsModel,
22
+ SELECTED,
23
+ NOT_SELECTED,
24
+ )
25
+ from portfolio_optimization.converters import plan_to_metrics
26
+
27
+
28
+ def create_stock(
29
+ stock_id: str,
30
+ sector: str = "Technology",
31
+ predicted_return: float = 0.10,
32
+ selected: bool = True
33
+ ) -> StockSelection:
34
+ """Create a test stock with sensible defaults."""
35
+ return StockSelection(
36
+ stock_id=stock_id,
37
+ stock_name=f"{stock_id} Corp",
38
+ sector=sector,
39
+ predicted_return=predicted_return,
40
+ selection=SELECTED if selected else NOT_SELECTED,
41
+ )
42
+
43
+
44
+ def create_plan(stocks: list[StockSelection]) -> PortfolioOptimizationPlan:
45
+ """Create a test plan with given stocks."""
46
+ return PortfolioOptimizationPlan(
47
+ stocks=stocks,
48
+ target_position_count=20,
49
+ max_sector_percentage=0.25,
50
+ portfolio_config=PortfolioConfig(target_count=20, max_per_sector=5, unselected_penalty=10000),
51
+ )
52
+
53
+
54
+ class TestHerfindahlIndex:
55
+ """Tests for the Herfindahl-Hirschman Index (HHI) calculation."""
56
+
57
+ def test_single_sector_hhi_is_one(self) -> None:
58
+ """All stocks in one sector should have HHI = 1.0 (max concentration)."""
59
+ stocks = [create_stock(f"STK{i}", sector="Technology") for i in range(5)]
60
+ plan = create_plan(stocks)
61
+
62
+ # All in one sector: HHI = 1.0^2 = 1.0
63
+ assert plan.get_herfindahl_index() == 1.0
64
+
65
+ def test_two_equal_sectors_hhi(self) -> None:
66
+ """Two sectors with equal stocks should have HHI = 0.5."""
67
+ stocks = [
68
+ *[create_stock(f"TECH{i}", sector="Technology") for i in range(5)],
69
+ *[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(5)],
70
+ ]
71
+ plan = create_plan(stocks)
72
+
73
+ # 50% in each sector: HHI = 0.5^2 + 0.5^2 = 0.5
74
+ assert abs(plan.get_herfindahl_index() - 0.5) < 0.001
75
+
76
+ def test_four_equal_sectors_hhi(self) -> None:
77
+ """Four sectors with equal stocks should have HHI = 0.25."""
78
+ stocks = [
79
+ *[create_stock(f"TECH{i}", sector="Technology") for i in range(5)],
80
+ *[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(5)],
81
+ *[create_stock(f"FIN{i}", sector="Finance") for i in range(5)],
82
+ *[create_stock(f"NRG{i}", sector="Energy") for i in range(5)],
83
+ ]
84
+ plan = create_plan(stocks)
85
+
86
+ # 25% in each sector: HHI = 4 * 0.25^2 = 0.25
87
+ assert abs(plan.get_herfindahl_index() - 0.25) < 0.001
88
+
89
+ def test_empty_portfolio_hhi_is_zero(self) -> None:
90
+ """Empty portfolio should have HHI = 0."""
91
+ stocks = [create_stock(f"STK{i}", selected=False) for i in range(5)]
92
+ plan = create_plan(stocks)
93
+
94
+ assert plan.get_herfindahl_index() == 0.0
95
+
96
+ def test_unequal_sectors_hhi(self) -> None:
97
+ """Unequal sector distribution should give correct HHI."""
98
+ stocks = [
99
+ *[create_stock(f"TECH{i}", sector="Technology") for i in range(6)], # 60%
100
+ *[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(4)], # 40%
101
+ ]
102
+ plan = create_plan(stocks)
103
+
104
+ # HHI = 0.6^2 + 0.4^2 = 0.36 + 0.16 = 0.52
105
+ assert abs(plan.get_herfindahl_index() - 0.52) < 0.001
106
+
107
+
108
+ class TestDiversificationScore:
109
+ """Tests for the diversification score (1 - HHI)."""
110
+
111
+ def test_single_sector_diversification_is_zero(self) -> None:
112
+ """All stocks in one sector should have diversification = 0."""
113
+ stocks = [create_stock(f"STK{i}", sector="Technology") for i in range(5)]
114
+ plan = create_plan(stocks)
115
+
116
+ assert plan.get_diversification_score() == 0.0
117
+
118
+ def test_two_equal_sectors_diversification(self) -> None:
119
+ """Two equal sectors should have diversification = 0.5."""
120
+ stocks = [
121
+ *[create_stock(f"TECH{i}", sector="Technology") for i in range(5)],
122
+ *[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(5)],
123
+ ]
124
+ plan = create_plan(stocks)
125
+
126
+ assert abs(plan.get_diversification_score() - 0.5) < 0.001
127
+
128
+ def test_four_equal_sectors_diversification(self) -> None:
129
+ """Four equal sectors should have diversification = 0.75."""
130
+ stocks = [
131
+ *[create_stock(f"TECH{i}", sector="Technology") for i in range(5)],
132
+ *[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(5)],
133
+ *[create_stock(f"FIN{i}", sector="Finance") for i in range(5)],
134
+ *[create_stock(f"NRG{i}", sector="Energy") for i in range(5)],
135
+ ]
136
+ plan = create_plan(stocks)
137
+
138
+ # 1 - HHI = 1 - 0.25 = 0.75
139
+ assert abs(plan.get_diversification_score() - 0.75) < 0.001
140
+
141
+
142
+ class TestMaxSectorExposure:
143
+ """Tests for max sector exposure calculation."""
144
+
145
+ def test_single_sector_max_exposure_is_one(self) -> None:
146
+ """All stocks in one sector should have max exposure = 1.0."""
147
+ stocks = [create_stock(f"STK{i}", sector="Technology") for i in range(5)]
148
+ plan = create_plan(stocks)
149
+
150
+ assert plan.get_max_sector_exposure() == 1.0
151
+
152
+ def test_two_equal_sectors_max_exposure(self) -> None:
153
+ """Two equal sectors should have max exposure = 0.5."""
154
+ stocks = [
155
+ *[create_stock(f"TECH{i}", sector="Technology") for i in range(5)],
156
+ *[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(5)],
157
+ ]
158
+ plan = create_plan(stocks)
159
+
160
+ assert abs(plan.get_max_sector_exposure() - 0.5) < 0.001
161
+
162
+ def test_unequal_sectors_max_exposure(self) -> None:
163
+ """Unequal sectors should return the larger weight."""
164
+ stocks = [
165
+ *[create_stock(f"TECH{i}", sector="Technology") for i in range(7)], # 70%
166
+ *[create_stock(f"HLTH{i}", sector="Healthcare") for i in range(3)], # 30%
167
+ ]
168
+ plan = create_plan(stocks)
169
+
170
+ assert abs(plan.get_max_sector_exposure() - 0.7) < 0.001
171
+
172
+ def test_empty_portfolio_max_exposure_is_zero(self) -> None:
173
+ """Empty portfolio should have max exposure = 0."""
174
+ stocks = [create_stock(f"STK{i}", selected=False) for i in range(5)]
175
+ plan = create_plan(stocks)
176
+
177
+ assert plan.get_max_sector_exposure() == 0.0
178
+
179
+
180
+ class TestSectorCount:
181
+ """Tests for sector count calculation."""
182
+
183
+ def test_single_sector(self) -> None:
184
+ """All stocks in one sector should return count = 1."""
185
+ stocks = [create_stock(f"STK{i}", sector="Technology") for i in range(5)]
186
+ plan = create_plan(stocks)
187
+
188
+ assert plan.get_sector_count() == 1
189
+
190
+ def test_multiple_sectors(self) -> None:
191
+ """Stocks in multiple sectors should return correct count."""
192
+ stocks = [
193
+ create_stock("TECH1", sector="Technology"),
194
+ create_stock("HLTH1", sector="Healthcare"),
195
+ create_stock("FIN1", sector="Finance"),
196
+ create_stock("NRG1", sector="Energy"),
197
+ ]
198
+ plan = create_plan(stocks)
199
+
200
+ assert plan.get_sector_count() == 4
201
+
202
+ def test_empty_portfolio_sector_count_is_zero(self) -> None:
203
+ """Empty portfolio should have sector count = 0."""
204
+ stocks = [create_stock(f"STK{i}", selected=False) for i in range(5)]
205
+ plan = create_plan(stocks)
206
+
207
+ assert plan.get_sector_count() == 0
208
+
209
+
210
+ class TestExpectedReturn:
211
+ """Tests for expected return calculation."""
212
+
213
+ def test_uniform_returns(self) -> None:
214
+ """Stocks with same returns should give that return."""
215
+ stocks = [create_stock(f"STK{i}", predicted_return=0.10) for i in range(5)]
216
+ plan = create_plan(stocks)
217
+
218
+ assert abs(plan.get_expected_return() - 0.10) < 0.001
219
+
220
+ def test_mixed_returns(self) -> None:
221
+ """Mixed returns should give weighted average."""
222
+ stocks = [
223
+ create_stock("STK1", predicted_return=0.10), # 10%
224
+ create_stock("STK2", predicted_return=0.20), # 20%
225
+ ]
226
+ plan = create_plan(stocks)
227
+
228
+ # Equal weight: (0.10 + 0.20) / 2 = 0.15
229
+ assert abs(plan.get_expected_return() - 0.15) < 0.001
230
+
231
+ def test_empty_portfolio_return_is_zero(self) -> None:
232
+ """Empty portfolio should have return = 0."""
233
+ stocks = [create_stock(f"STK{i}", selected=False) for i in range(5)]
234
+ plan = create_plan(stocks)
235
+
236
+ assert plan.get_expected_return() == 0.0
237
+
238
+
239
+ class TestReturnVolatility:
240
+ """Tests for return volatility (std dev) calculation."""
241
+
242
+ def test_uniform_returns_zero_volatility(self) -> None:
243
+ """All same returns should give volatility = 0."""
244
+ stocks = [create_stock(f"STK{i}", predicted_return=0.10) for i in range(5)]
245
+ plan = create_plan(stocks)
246
+
247
+ assert plan.get_return_volatility() == 0.0
248
+
249
+ def test_varied_returns_nonzero_volatility(self) -> None:
250
+ """Varied returns should give positive volatility."""
251
+ stocks = [
252
+ create_stock("STK1", predicted_return=0.05),
253
+ create_stock("STK2", predicted_return=0.10),
254
+ create_stock("STK3", predicted_return=0.15),
255
+ create_stock("STK4", predicted_return=0.20),
256
+ ]
257
+ plan = create_plan(stocks)
258
+
259
+ # Mean = 0.125, variance = ((0.05-0.125)^2 + (0.10-0.125)^2 + (0.15-0.125)^2 + (0.20-0.125)^2) / 4
260
+ # = (0.005625 + 0.000625 + 0.000625 + 0.005625) / 4 = 0.003125
261
+ # Std dev = sqrt(0.003125) ≈ 0.0559
262
+ expected_vol = math.sqrt(0.003125)
263
+ assert abs(plan.get_return_volatility() - expected_vol) < 0.0001
264
+
265
+ def test_single_stock_zero_volatility(self) -> None:
266
+ """Single stock should have volatility = 0 (need at least 2)."""
267
+ stocks = [create_stock("STK1", predicted_return=0.10)]
268
+ plan = create_plan(stocks)
269
+
270
+ assert plan.get_return_volatility() == 0.0
271
+
272
+
273
+ class TestSharpeProxy:
274
+ """Tests for Sharpe ratio proxy calculation."""
275
+
276
+ def test_positive_sharpe(self) -> None:
277
+ """Positive return with volatility should give positive Sharpe."""
278
+ stocks = [
279
+ create_stock("STK1", predicted_return=0.05),
280
+ create_stock("STK2", predicted_return=0.10),
281
+ create_stock("STK3", predicted_return=0.15),
282
+ create_stock("STK4", predicted_return=0.20),
283
+ ]
284
+ plan = create_plan(stocks)
285
+
286
+ # Return = 0.125, volatility = 0.0559
287
+ # Sharpe = 0.125 / 0.0559 ≈ 2.24
288
+ sharpe = plan.get_sharpe_proxy()
289
+ assert sharpe > 2.0
290
+ assert sharpe < 2.5
291
+
292
+ def test_zero_volatility_zero_sharpe(self) -> None:
293
+ """Zero volatility should give Sharpe = 0 (undefined)."""
294
+ stocks = [create_stock(f"STK{i}", predicted_return=0.10) for i in range(5)]
295
+ plan = create_plan(stocks)
296
+
297
+ assert plan.get_sharpe_proxy() == 0.0
298
+
299
+ def test_empty_portfolio_zero_sharpe(self) -> None:
300
+ """Empty portfolio should have Sharpe = 0."""
301
+ stocks = [create_stock(f"STK{i}", selected=False) for i in range(5)]
302
+ plan = create_plan(stocks)
303
+
304
+ assert plan.get_sharpe_proxy() == 0.0
305
+
306
+
307
+ class TestPlanToMetrics:
308
+ """Tests for the plan_to_metrics converter function."""
309
+
310
+ def test_metrics_from_valid_portfolio(self) -> None:
311
+ """plan_to_metrics should return all metrics for valid portfolio."""
312
+ stocks = [
313
+ *[create_stock(f"TECH{i}", sector="Technology", predicted_return=0.12) for i in range(5)],
314
+ *[create_stock(f"HLTH{i}", sector="Healthcare", predicted_return=0.08) for i in range(5)],
315
+ ]
316
+ plan = create_plan(stocks)
317
+
318
+ metrics = plan_to_metrics(plan)
319
+
320
+ assert metrics is not None
321
+ assert isinstance(metrics, PortfolioMetricsModel)
322
+ assert metrics.sector_count == 2
323
+ assert abs(metrics.expected_return - 0.10) < 0.001
324
+ assert abs(metrics.diversification_score - 0.5) < 0.001
325
+ assert abs(metrics.herfindahl_index - 0.5) < 0.001
326
+ assert abs(metrics.max_sector_exposure - 0.5) < 0.001
327
+
328
+ def test_metrics_from_empty_portfolio_is_none(self) -> None:
329
+ """plan_to_metrics should return None for empty portfolio."""
330
+ stocks = [create_stock(f"STK{i}", selected=False) for i in range(5)]
331
+ plan = create_plan(stocks)
332
+
333
+ metrics = plan_to_metrics(plan)
334
+
335
+ assert metrics is None
336
+
337
+ def test_metrics_serialization(self) -> None:
338
+ """Metrics should serialize with camelCase aliases."""
339
+ stocks = [create_stock(f"STK{i}") for i in range(5)]
340
+ plan = create_plan(stocks)
341
+
342
+ metrics = plan_to_metrics(plan)
343
+ assert metrics is not None
344
+
345
+ data = metrics.model_dump(by_alias=True)
346
+ assert "expectedReturn" in data
347
+ assert "sectorCount" in data
348
+ assert "maxSectorExposure" in data
349
+ assert "herfindahlIndex" in data
350
+ assert "diversificationScore" in data
351
+ assert "returnVolatility" in data
352
+ assert "sharpeProxy" in data
tests/test_constraints.py ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Constraint tests for the Portfolio Optimization quickstart.
3
+
4
+ Each constraint is tested with both penalizing and non-penalizing scenarios.
5
+ This ensures the constraints correctly encode the business rules.
6
+
7
+ Test Patterns:
8
+ 1. Create minimal test data (just what's needed for the test)
9
+ 2. Use constraint_verifier to check penalties/rewards
10
+ 3. Provide PortfolioConfig as problem fact for parameterized constraints
11
+
12
+ Finance Concepts Tested:
13
+ - Stock selection (configurable target, default 20)
14
+ - Sector diversification (configurable max per sector, default 5)
15
+ - Return maximization (prefer high-return stocks)
16
+ """
17
+ from solverforge_legacy.solver.test import ConstraintVerifier
18
+
19
+ from portfolio_optimization.domain import (
20
+ StockSelection,
21
+ PortfolioOptimizationPlan,
22
+ PortfolioConfig,
23
+ SelectionValue,
24
+ SELECTED,
25
+ NOT_SELECTED,
26
+ )
27
+ from portfolio_optimization.constraints import (
28
+ define_constraints,
29
+ must_select_target_count,
30
+ penalize_unselected_stock,
31
+ sector_exposure_limit,
32
+ maximize_expected_return,
33
+ )
34
+
35
+ import pytest
36
+
37
+
38
+ # Create constraint verifier for testing
39
+ constraint_verifier = ConstraintVerifier.build(
40
+ define_constraints, PortfolioOptimizationPlan, StockSelection
41
+ )
42
+
43
+ # Default config matches historical defaults
44
+ DEFAULT_CONFIG = PortfolioConfig(target_count=20, max_per_sector=5, unselected_penalty=10000)
45
+
46
+
47
+ # ========================================
48
+ # Helper Functions
49
+ # ========================================
50
+
51
+ def create_stock(
52
+ stock_id: str,
53
+ sector: str = "Technology",
54
+ predicted_return: float = 0.10,
55
+ selected: bool = True
56
+ ) -> StockSelection:
57
+ """Create a test stock with sensible defaults.
58
+
59
+ Args:
60
+ stock_id: Unique identifier for the stock
61
+ sector: Industry sector (default "Technology")
62
+ predicted_return: ML-predicted return as decimal (default 0.10 = 10%)
63
+ selected: If True, stock is selected for portfolio. If False, not selected.
64
+
65
+ Returns:
66
+ StockSelection with the specified parameters
67
+ """
68
+ # Convert boolean to SelectionValue for the planning variable
69
+ selection_value = SELECTED if selected else NOT_SELECTED
70
+
71
+ return StockSelection(
72
+ stock_id=stock_id,
73
+ stock_name=f"{stock_id} Corp",
74
+ sector=sector,
75
+ predicted_return=predicted_return,
76
+ selection=selection_value,
77
+ )
78
+
79
+
80
+ # ========================================
81
+ # Must Select Target Count Tests
82
+ # ========================================
83
+
84
+ class TestMustSelectTargetCount:
85
+ """Tests for the must_select_target_count constraint.
86
+
87
+ This is a parameterized constraint that reads target_count from PortfolioConfig.
88
+ Default is 20 stocks. Only penalizes when count EXCEEDS target.
89
+ """
90
+
91
+ def test_exactly_target_no_penalty(self) -> None:
92
+ """Selecting exactly target_count stocks should not be penalized."""
93
+ stocks = [create_stock(f"STK{i}", selected=True) for i in range(20)]
94
+
95
+ constraint_verifier.verify_that(must_select_target_count).given(
96
+ *stocks, DEFAULT_CONFIG
97
+ ).penalizes(0)
98
+
99
+ def test_one_over_target_penalizes_1(self) -> None:
100
+ """Selecting target_count + 1 stocks should be penalized by 1."""
101
+ stocks = [create_stock(f"STK{i}", selected=True) for i in range(21)]
102
+
103
+ constraint_verifier.verify_that(must_select_target_count).given(
104
+ *stocks, DEFAULT_CONFIG
105
+ ).penalizes_by(1)
106
+
107
+ def test_five_over_target_penalizes_5(self) -> None:
108
+ """Selecting target_count + 5 stocks should be penalized by 5."""
109
+ stocks = [create_stock(f"STK{i}", selected=True) for i in range(25)]
110
+
111
+ constraint_verifier.verify_that(must_select_target_count).given(
112
+ *stocks, DEFAULT_CONFIG
113
+ ).penalizes_by(5)
114
+
115
+ def test_under_target_no_penalty(self) -> None:
116
+ """The max constraint doesn't penalize for too few stocks."""
117
+ stocks = [create_stock(f"STK{i}", selected=True) for i in range(19)]
118
+
119
+ constraint_verifier.verify_that(must_select_target_count).given(
120
+ *stocks, DEFAULT_CONFIG
121
+ ).penalizes(0)
122
+
123
+ def test_custom_target_10(self) -> None:
124
+ """Custom target_count=10: 11 stocks should penalize by 1."""
125
+ config = PortfolioConfig(target_count=10, max_per_sector=5, unselected_penalty=10000)
126
+ stocks = [create_stock(f"STK{i}", selected=True) for i in range(11)]
127
+
128
+ constraint_verifier.verify_that(must_select_target_count).given(
129
+ *stocks, config
130
+ ).penalizes_by(1)
131
+
132
+ def test_custom_target_30(self) -> None:
133
+ """Custom target_count=30: exactly 30 stocks should not penalize."""
134
+ config = PortfolioConfig(target_count=30, max_per_sector=8, unselected_penalty=10000)
135
+ stocks = [create_stock(f"STK{i}", selected=True) for i in range(30)]
136
+
137
+ constraint_verifier.verify_that(must_select_target_count).given(
138
+ *stocks, config
139
+ ).penalizes(0)
140
+
141
+
142
+ class TestPenalizeUnselectedStock:
143
+ """Tests for the penalize_unselected_stock soft constraint.
144
+
145
+ This is a parameterized constraint that reads unselected_penalty from PortfolioConfig.
146
+ Default penalty is 10000 per unselected stock.
147
+ It drives the solver to select stocks without affecting hard feasibility.
148
+ """
149
+
150
+ def test_selected_stock_no_penalty(self) -> None:
151
+ """A selected stock should not be penalized."""
152
+ stock = create_stock("STK1", selected=True)
153
+
154
+ constraint_verifier.verify_that(penalize_unselected_stock).given(
155
+ stock, DEFAULT_CONFIG
156
+ ).penalizes(0)
157
+
158
+ def test_unselected_stock_penalized(self) -> None:
159
+ """An unselected stock should be penalized by unselected_penalty (default 10000)."""
160
+ stock = create_stock("STK1", selected=False)
161
+
162
+ # 1 unselected * 10000 penalty = 10000
163
+ constraint_verifier.verify_that(penalize_unselected_stock).given(
164
+ stock, DEFAULT_CONFIG
165
+ ).penalizes_by(10000)
166
+
167
+ def test_mixed_selection(self) -> None:
168
+ """Mix of selected and unselected stocks - only unselected penalized."""
169
+ selected = [create_stock(f"SEL{i}", selected=True) for i in range(10)]
170
+ unselected = [create_stock(f"UNS{i}", selected=False) for i in range(5)]
171
+
172
+ # 5 unselected * 10000 penalty = 50000
173
+ constraint_verifier.verify_that(penalize_unselected_stock).given(
174
+ *selected, *unselected, DEFAULT_CONFIG
175
+ ).penalizes_by(50000)
176
+
177
+ def test_custom_penalty(self) -> None:
178
+ """Custom unselected_penalty=5000: 2 unselected should penalize by 10000."""
179
+ config = PortfolioConfig(target_count=20, max_per_sector=5, unselected_penalty=5000)
180
+ unselected = [create_stock(f"UNS{i}", selected=False) for i in range(2)]
181
+
182
+ # 2 unselected * 5000 penalty = 10000
183
+ constraint_verifier.verify_that(penalize_unselected_stock).given(
184
+ *unselected, config
185
+ ).penalizes_by(10000)
186
+
187
+
188
+ # ========================================
189
+ # Sector Exposure Limit Tests
190
+ # ========================================
191
+
192
+ class TestSectorExposureLimit:
193
+ """Tests for the sector_exposure_limit constraint.
194
+
195
+ This is a parameterized constraint that reads max_per_sector from PortfolioConfig.
196
+ Default is 5 stocks per sector (= 25% with 20 total stocks).
197
+ """
198
+
199
+ def test_at_limit_no_penalty(self) -> None:
200
+ """Having exactly max_per_sector stocks in each sector should not be penalized."""
201
+ # 5 tech + 5 healthcare + 5 finance + 5 energy = 20 stocks, all at limit
202
+ tech = [create_stock(f"TECH{i}", sector="Technology", selected=True) for i in range(5)]
203
+ health = [create_stock(f"HLTH{i}", sector="Healthcare", selected=True) for i in range(5)]
204
+ finance = [create_stock(f"FIN{i}", sector="Finance", selected=True) for i in range(5)]
205
+ energy = [create_stock(f"NRG{i}", sector="Energy", selected=True) for i in range(5)]
206
+
207
+ constraint_verifier.verify_that(sector_exposure_limit).given(
208
+ *tech, *health, *finance, *energy, DEFAULT_CONFIG
209
+ ).penalizes(0)
210
+
211
+ def test_one_over_limit_penalizes_1(self) -> None:
212
+ """Having max_per_sector + 1 stocks in a sector should be penalized by 1."""
213
+ tech = [create_stock(f"TECH{i}", sector="Technology", selected=True) for i in range(6)]
214
+
215
+ constraint_verifier.verify_that(sector_exposure_limit).given(
216
+ *tech, DEFAULT_CONFIG
217
+ ).penalizes_by(1)
218
+
219
+ def test_three_over_limit_penalizes_3(self) -> None:
220
+ """Having max_per_sector + 3 stocks in a sector should be penalized by 3."""
221
+ tech = [create_stock(f"TECH{i}", sector="Technology", selected=True) for i in range(8)]
222
+
223
+ constraint_verifier.verify_that(sector_exposure_limit).given(
224
+ *tech, DEFAULT_CONFIG
225
+ ).penalizes_by(3)
226
+
227
+ def test_multiple_sectors_over_limit(self) -> None:
228
+ """Multiple sectors over limit should each contribute penalty."""
229
+ # 6 tech (penalty 1) + 7 healthcare (penalty 2) = total penalty 3
230
+ tech = [create_stock(f"TECH{i}", sector="Technology", selected=True) for i in range(6)]
231
+ health = [create_stock(f"HLTH{i}", sector="Healthcare", selected=True) for i in range(7)]
232
+
233
+ constraint_verifier.verify_that(sector_exposure_limit).given(
234
+ *tech, *health, DEFAULT_CONFIG
235
+ ).penalizes_by(3)
236
+
237
+ def test_unselected_stocks_not_counted(self) -> None:
238
+ """Unselected stocks should not count toward sector limits."""
239
+ # 5 selected tech (at limit) + 5 unselected tech (ignored) = no penalty
240
+ selected = [create_stock(f"STECH{i}", sector="Technology", selected=True) for i in range(5)]
241
+ unselected = [create_stock(f"UTECH{i}", sector="Technology", selected=False) for i in range(5)]
242
+
243
+ constraint_verifier.verify_that(sector_exposure_limit).given(
244
+ *selected, *unselected, DEFAULT_CONFIG
245
+ ).penalizes(0)
246
+
247
+ def test_single_sector_at_limit_no_penalty(self) -> None:
248
+ """A single sector with exactly max_per_sector stocks should not be penalized."""
249
+ stocks = [create_stock(f"TECH{i}", sector="Technology", selected=True) for i in range(5)]
250
+
251
+ constraint_verifier.verify_that(sector_exposure_limit).given(
252
+ *stocks, DEFAULT_CONFIG
253
+ ).penalizes(0)
254
+
255
+ def test_custom_max_per_sector_3(self) -> None:
256
+ """Custom max_per_sector=3: 4 stocks should penalize by 1."""
257
+ config = PortfolioConfig(target_count=15, max_per_sector=3, unselected_penalty=10000)
258
+ tech = [create_stock(f"TECH{i}", sector="Technology", selected=True) for i in range(4)]
259
+
260
+ constraint_verifier.verify_that(sector_exposure_limit).given(
261
+ *tech, config
262
+ ).penalizes_by(1)
263
+
264
+ def test_custom_max_per_sector_8(self) -> None:
265
+ """Custom max_per_sector=8: 8 stocks should not penalize."""
266
+ config = PortfolioConfig(target_count=30, max_per_sector=8, unselected_penalty=10000)
267
+ tech = [create_stock(f"TECH{i}", sector="Technology", selected=True) for i in range(8)]
268
+
269
+ constraint_verifier.verify_that(sector_exposure_limit).given(
270
+ *tech, config
271
+ ).penalizes(0)
272
+
273
+
274
+ # ========================================
275
+ # Maximize Expected Return Tests
276
+ # ========================================
277
+
278
+ class TestMaximizeExpectedReturn:
279
+ """Tests for the maximize_expected_return constraint.
280
+
281
+ This constraint rewards selected stocks based on predicted_return * 10000.
282
+ It does not use PortfolioConfig (not parameterized).
283
+ """
284
+
285
+ def test_high_return_stock_rewarded(self) -> None:
286
+ """Stock with 12% predicted return should be rewarded 1200 points."""
287
+ # 0.12 * 10000 = 1200
288
+ stock = create_stock("AAPL", predicted_return=0.12, selected=True)
289
+
290
+ constraint_verifier.verify_that(maximize_expected_return).given(
291
+ stock
292
+ ).rewards_with(1200)
293
+
294
+ def test_low_return_stock_rewarded_less(self) -> None:
295
+ """Stock with 5% predicted return should be rewarded 500 points."""
296
+ # 0.05 * 10000 = 500
297
+ stock = create_stock("INTC", predicted_return=0.05, selected=True)
298
+
299
+ constraint_verifier.verify_that(maximize_expected_return).given(
300
+ stock
301
+ ).rewards_with(500)
302
+
303
+ def test_multiple_stocks_reward_sum(self) -> None:
304
+ """Multiple selected stocks should have rewards summed."""
305
+ # 0.10 * 10000 = 1000, 0.15 * 10000 = 1500, total = 2500
306
+ stock1 = create_stock("STK1", predicted_return=0.10, selected=True)
307
+ stock2 = create_stock("STK2", predicted_return=0.15, selected=True)
308
+
309
+ constraint_verifier.verify_that(maximize_expected_return).given(
310
+ stock1, stock2
311
+ ).rewards_with(2500)
312
+
313
+ def test_unselected_stock_not_rewarded(self) -> None:
314
+ """Unselected stocks should not contribute to reward."""
315
+ stock = create_stock("STK1", predicted_return=0.20, selected=False)
316
+
317
+ constraint_verifier.verify_that(maximize_expected_return).given(
318
+ stock
319
+ ).rewards(0)
320
+
321
+
322
+ # ========================================
323
+ # Integration Tests
324
+ # ========================================
325
+
326
+ class TestIntegration:
327
+ """Integration tests for the complete constraint set."""
328
+
329
+ def test_valid_portfolio_no_sector_violations(self) -> None:
330
+ """A valid portfolio should have 0 hard constraint violations."""
331
+ # Create valid portfolio: 20 stocks, max 5 per sector
332
+ tech = [create_stock(f"TECH{i}", sector="Technology", predicted_return=0.15, selected=True) for i in range(5)]
333
+ health = [create_stock(f"HLTH{i}", sector="Healthcare", predicted_return=0.10, selected=True) for i in range(5)]
334
+ finance = [create_stock(f"FIN{i}", sector="Finance", predicted_return=0.08, selected=True) for i in range(5)]
335
+ energy = [create_stock(f"NRG{i}", sector="Energy", predicted_return=0.05, selected=True) for i in range(5)]
336
+
337
+ all_stocks = tech + health + finance + energy
338
+
339
+ # Verify no hard constraint violations
340
+ constraint_verifier.verify_that(sector_exposure_limit).given(*all_stocks, DEFAULT_CONFIG).penalizes(0)
341
+ constraint_verifier.verify_that(must_select_target_count).given(*all_stocks, DEFAULT_CONFIG).penalizes(0)
342
+
343
+ def test_high_return_portfolio_preferred(self) -> None:
344
+ """Higher return stocks should result in higher soft score."""
345
+ # Portfolio A: all 10% return stocks
346
+ low_return = [create_stock(f"LOW{i}", predicted_return=0.10, selected=True) for i in range(5)]
347
+
348
+ # Portfolio B: all 15% return stocks
349
+ high_return = [create_stock(f"HIGH{i}", predicted_return=0.15, selected=True) for i in range(5)]
350
+
351
+ # Calculate rewards
352
+ low_reward = 5 * 1000 # 5 stocks * 0.10 * 10000
353
+ high_reward = 5 * 1500 # 5 stocks * 0.15 * 10000
354
+
355
+ constraint_verifier.verify_that(maximize_expected_return).given(*low_return).rewards_with(low_reward)
356
+ constraint_verifier.verify_that(maximize_expected_return).given(*high_return).rewards_with(high_reward)
357
+
358
+ assert high_reward > low_reward, "High return portfolio should have higher reward"
359
+
360
+ def test_custom_config_integration(self) -> None:
361
+ """Test that custom config values are respected across constraints."""
362
+ # Custom config: target 10 stocks, max 3 per sector
363
+ config = PortfolioConfig(target_count=10, max_per_sector=3, unselected_penalty=5000)
364
+
365
+ # Create portfolio: 10 stocks total, 3 per sector (within limits)
366
+ tech = [create_stock(f"TECH{i}", sector="Technology", selected=True) for i in range(3)]
367
+ health = [create_stock(f"HLTH{i}", sector="Healthcare", selected=True) for i in range(3)]
368
+ finance = [create_stock(f"FIN{i}", sector="Finance", selected=True) for i in range(3)]
369
+ energy = [create_stock(f"NRG{i}", sector="Energy", selected=True) for i in range(1)]
370
+ unselected = [create_stock(f"UNS{i}", sector="Other", selected=False) for i in range(2)]
371
+
372
+ all_stocks = tech + health + finance + energy + unselected
373
+
374
+ # Verify: 10 selected stocks should not penalize target count constraint
375
+ constraint_verifier.verify_that(must_select_target_count).given(*all_stocks, config).penalizes(0)
376
+
377
+ # Verify: 3 per sector should not penalize sector limit
378
+ constraint_verifier.verify_that(sector_exposure_limit).given(*all_stocks, config).penalizes(0)
379
+
380
+ # Verify: 2 unselected * 5000 penalty = 10000
381
+ constraint_verifier.verify_that(penalize_unselected_stock).given(*all_stocks, config).penalizes_by(10000)
tests/test_feasible.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Feasibility tests for the Portfolio Optimization quickstart.
3
+
4
+ These tests verify that the solver can find valid solutions
5
+ for the demo datasets.
6
+ """
7
+ from solverforge_legacy.solver import SolverFactory
8
+ from solverforge_legacy.solver.config import (
9
+ SolverConfig,
10
+ ScoreDirectorFactoryConfig,
11
+ TerminationConfig,
12
+ Duration,
13
+ )
14
+
15
+ from portfolio_optimization.domain import PortfolioOptimizationPlan, StockSelection
16
+ from portfolio_optimization.constraints import define_constraints
17
+ from portfolio_optimization.demo_data import generate_demo_data, DemoData
18
+
19
+ import pytest
20
+
21
+
22
+ def solve_portfolio(plan: PortfolioOptimizationPlan, seconds: int = 5) -> PortfolioOptimizationPlan:
23
+ """Run the solver on a portfolio for a given number of seconds."""
24
+ solver_config = SolverConfig(
25
+ solution_class=PortfolioOptimizationPlan,
26
+ entity_class_list=[StockSelection],
27
+ score_director_factory_config=ScoreDirectorFactoryConfig(
28
+ constraint_provider_function=define_constraints
29
+ ),
30
+ termination_config=TerminationConfig(spent_limit=Duration(seconds=seconds)),
31
+ )
32
+
33
+ solver = SolverFactory.create(solver_config).build_solver()
34
+ return solver.solve(plan)
35
+
36
+
37
+ class TestFeasibility:
38
+ """Test that the solver can find feasible solutions."""
39
+
40
+ def test_small_dataset_feasible(self):
41
+ """The SMALL dataset should be solvable to a feasible solution."""
42
+ plan = generate_demo_data(DemoData.SMALL)
43
+
44
+ solution = solve_portfolio(plan, seconds=10)
45
+
46
+ # Check that we got a solution
47
+ assert solution is not None
48
+ assert solution.score is not None
49
+
50
+ # Check feasibility (hard score = 0)
51
+ assert solution.score.hard_score == 0, \
52
+ f"Solution should be feasible, got hard score: {solution.score.hard_score}"
53
+
54
+ # Check we selected exactly 20 stocks
55
+ selected_count = solution.get_selected_count()
56
+ assert selected_count == 20, \
57
+ f"Should select 20 stocks, got {selected_count}"
58
+
59
+ def test_large_dataset_feasible(self):
60
+ """The LARGE dataset should be solvable to a feasible solution."""
61
+ plan = generate_demo_data(DemoData.LARGE)
62
+
63
+ solution = solve_portfolio(plan, seconds=15)
64
+
65
+ # Check that we got a solution
66
+ assert solution is not None
67
+ assert solution.score is not None
68
+
69
+ # Check feasibility (hard score = 0)
70
+ assert solution.score.hard_score == 0, \
71
+ f"Solution should be feasible, got hard score: {solution.score.hard_score}"
72
+
73
+ # Check we selected exactly 20 stocks
74
+ selected_count = solution.get_selected_count()
75
+ assert selected_count == 20, \
76
+ f"Should select 20 stocks, got {selected_count}"
77
+
78
+ def test_sector_limits_respected(self):
79
+ """The solver should respect sector exposure limits."""
80
+ plan = generate_demo_data(DemoData.SMALL)
81
+
82
+ solution = solve_portfolio(plan, seconds=10)
83
+
84
+ # Check sector weights
85
+ sector_weights = solution.get_sector_weights()
86
+
87
+ for sector, weight in sector_weights.items():
88
+ assert weight <= 0.26, \
89
+ f"Sector {sector} has {weight*100:.1f}% weight, exceeds 25% limit"
90
+
91
+ def test_positive_expected_return(self):
92
+ """The solver should find a portfolio with positive expected return."""
93
+ plan = generate_demo_data(DemoData.SMALL)
94
+
95
+ solution = solve_portfolio(plan, seconds=10)
96
+
97
+ expected_return = solution.get_expected_return()
98
+
99
+ # With our demo data, we should get at least 5% expected return
100
+ assert expected_return > 0.05, \
101
+ f"Expected return should be > 5%, got {expected_return*100:.2f}%"
102
+
103
+ def test_expected_return_reasonable(self):
104
+ """The expected return should be reasonable for valid solutions."""
105
+ plan = generate_demo_data(DemoData.SMALL)
106
+
107
+ solution = solve_portfolio(plan, seconds=10)
108
+
109
+ # Check expected return is positive
110
+ expected_return = solution.get_expected_return()
111
+ assert expected_return > 0, \
112
+ f"Expected return should be positive, got {expected_return}"
113
+
114
+
115
+ class TestDemoData:
116
+ """Test demo data generation."""
117
+
118
+ def test_small_dataset_has_25_stocks(self):
119
+ """SMALL dataset should have 25 stocks (5+ per sector for feasibility)."""
120
+ plan = generate_demo_data(DemoData.SMALL)
121
+
122
+ assert len(plan.stocks) == 25
123
+
124
+ def test_large_dataset_has_51_stocks(self):
125
+ """LARGE dataset should have 51 stocks."""
126
+ plan = generate_demo_data(DemoData.LARGE)
127
+
128
+ assert len(plan.stocks) == 51
129
+
130
+ def test_stocks_have_sectors(self):
131
+ """All stocks should have a sector assigned."""
132
+ plan = generate_demo_data(DemoData.SMALL)
133
+
134
+ for stock in plan.stocks:
135
+ assert stock.sector is not None
136
+ assert len(stock.sector) > 0
137
+
138
+ def test_stocks_have_predictions(self):
139
+ """All stocks should have predicted returns."""
140
+ plan = generate_demo_data(DemoData.SMALL)
141
+
142
+ for stock in plan.stocks:
143
+ assert stock.predicted_return is not None
144
+ # Predictions should be reasonable (-10% to +25%)
145
+ assert -0.10 <= stock.predicted_return <= 0.25
146
+
147
+ def test_stocks_initially_unselected(self):
148
+ """All stocks should start with selected=None."""
149
+ plan = generate_demo_data(DemoData.SMALL)
150
+
151
+ for stock in plan.stocks:
152
+ assert stock.selected is None
153
+
154
+ def test_has_multiple_sectors(self):
155
+ """Demo data should have multiple sectors for diversification testing."""
156
+ plan = generate_demo_data(DemoData.SMALL)
157
+
158
+ sectors = {stock.sector for stock in plan.stocks}
159
+
160
+ assert len(sectors) >= 4, \
161
+ f"Should have at least 4 sectors for diversification, got {len(sectors)}"
tests/test_portfolio_config.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for PortfolioConfig - the constraint configuration dataclass.
3
+
4
+ PortfolioConfig holds the threshold values that constraints use:
5
+ - target_count: Number of stocks to select (default 20)
6
+ - max_per_sector: Maximum stocks per sector (default 5)
7
+ - unselected_penalty: Soft penalty per unselected stock (default 10000)
8
+
9
+ These tests verify:
10
+ 1. PortfolioConfig dataclass behavior (defaults, equality, hashing)
11
+ 2. Integration with converters (model_to_plan creates correct config)
12
+ 3. Integration with demo_data (generate_demo_data creates correct config)
13
+ """
14
+ import pytest
15
+ from dataclasses import FrozenInstanceError
16
+
17
+ from portfolio_optimization.domain import (
18
+ PortfolioConfig,
19
+ PortfolioOptimizationPlan,
20
+ PortfolioOptimizationPlanModel,
21
+ StockSelectionModel,
22
+ )
23
+ from portfolio_optimization.converters import model_to_plan
24
+ from portfolio_optimization.demo_data import generate_demo_data, DemoData
25
+
26
+
27
+ class TestPortfolioConfigDataclass:
28
+ """Tests for the PortfolioConfig dataclass itself."""
29
+
30
+ def test_default_values(self) -> None:
31
+ """PortfolioConfig should have sensible defaults."""
32
+ config = PortfolioConfig()
33
+ assert config.target_count == 20
34
+ assert config.max_per_sector == 5
35
+ assert config.unselected_penalty == 10000
36
+
37
+ def test_custom_values(self) -> None:
38
+ """PortfolioConfig should accept custom values."""
39
+ config = PortfolioConfig(
40
+ target_count=30,
41
+ max_per_sector=8,
42
+ unselected_penalty=5000
43
+ )
44
+ assert config.target_count == 30
45
+ assert config.max_per_sector == 8
46
+ assert config.unselected_penalty == 5000
47
+
48
+ def test_equality_same_values(self) -> None:
49
+ """Two PortfolioConfigs with same values should be equal."""
50
+ config1 = PortfolioConfig(target_count=10, max_per_sector=3, unselected_penalty=10000)
51
+ config2 = PortfolioConfig(target_count=10, max_per_sector=3, unselected_penalty=10000)
52
+ assert config1 == config2
53
+
54
+ def test_equality_different_values(self) -> None:
55
+ """Two PortfolioConfigs with different values should not be equal."""
56
+ config1 = PortfolioConfig(target_count=10, max_per_sector=3, unselected_penalty=10000)
57
+ config2 = PortfolioConfig(target_count=20, max_per_sector=5, unselected_penalty=10000)
58
+ assert config1 != config2
59
+
60
+ def test_equality_different_penalty(self) -> None:
61
+ """PortfolioConfigs with different penalties should not be equal."""
62
+ config1 = PortfolioConfig(target_count=20, max_per_sector=5, unselected_penalty=10000)
63
+ config2 = PortfolioConfig(target_count=20, max_per_sector=5, unselected_penalty=5000)
64
+ assert config1 != config2
65
+
66
+ def test_hash_same_values(self) -> None:
67
+ """Two PortfolioConfigs with same values should have same hash."""
68
+ config1 = PortfolioConfig(target_count=10, max_per_sector=3, unselected_penalty=10000)
69
+ config2 = PortfolioConfig(target_count=10, max_per_sector=3, unselected_penalty=10000)
70
+ assert hash(config1) == hash(config2)
71
+
72
+ def test_hash_different_values(self) -> None:
73
+ """Two PortfolioConfigs with different values should (likely) have different hash."""
74
+ config1 = PortfolioConfig(target_count=10, max_per_sector=3, unselected_penalty=10000)
75
+ config2 = PortfolioConfig(target_count=20, max_per_sector=5, unselected_penalty=10000)
76
+ # Hash collision is possible but unlikely
77
+ assert hash(config1) != hash(config2)
78
+
79
+ def test_usable_as_dict_key(self) -> None:
80
+ """PortfolioConfig should be usable as a dictionary key."""
81
+ config = PortfolioConfig(target_count=15, max_per_sector=4, unselected_penalty=8000)
82
+ d = {config: "value"}
83
+ assert d[config] == "value"
84
+
85
+ def test_usable_in_set(self) -> None:
86
+ """PortfolioConfig should be usable in a set."""
87
+ config1 = PortfolioConfig(target_count=10, max_per_sector=3, unselected_penalty=10000)
88
+ config2 = PortfolioConfig(target_count=10, max_per_sector=3, unselected_penalty=10000)
89
+ config3 = PortfolioConfig(target_count=20, max_per_sector=5, unselected_penalty=10000)
90
+
91
+ s = {config1, config2, config3}
92
+ # config1 and config2 are equal, so set should have 2 items
93
+ assert len(s) == 2
94
+
95
+
96
+ class TestPortfolioConfigInConverters:
97
+ """Tests for PortfolioConfig creation in converters.model_to_plan()."""
98
+
99
+ def _create_plan_model(
100
+ self,
101
+ target_position_count: int = 20,
102
+ max_sector_percentage: float = 0.25
103
+ ) -> PortfolioOptimizationPlanModel:
104
+ """Helper to create a minimal plan model for testing."""
105
+ return PortfolioOptimizationPlanModel(
106
+ stocks=[
107
+ StockSelectionModel(
108
+ stock_id="TEST",
109
+ stock_name="Test Corp",
110
+ sector="Technology",
111
+ predicted_return=0.10,
112
+ selected=None
113
+ )
114
+ ],
115
+ target_position_count=target_position_count,
116
+ max_sector_percentage=max_sector_percentage
117
+ )
118
+
119
+ def test_model_to_plan_creates_config(self) -> None:
120
+ """model_to_plan should create a PortfolioConfig."""
121
+ model = self._create_plan_model()
122
+ plan = model_to_plan(model)
123
+ assert plan.portfolio_config is not None
124
+ assert isinstance(plan.portfolio_config, PortfolioConfig)
125
+
126
+ def test_model_to_plan_config_has_correct_target(self) -> None:
127
+ """model_to_plan should set target_count from target_position_count."""
128
+ model = self._create_plan_model(target_position_count=30)
129
+ plan = model_to_plan(model)
130
+ assert plan.portfolio_config.target_count == 30
131
+
132
+ def test_model_to_plan_config_calculates_max_per_sector(self) -> None:
133
+ """model_to_plan should calculate max_per_sector from percentage * target."""
134
+ # 25% of 20 = 5
135
+ model = self._create_plan_model(target_position_count=20, max_sector_percentage=0.25)
136
+ plan = model_to_plan(model)
137
+ assert plan.portfolio_config.max_per_sector == 5
138
+
139
+ def test_model_to_plan_config_calculates_max_per_sector_30(self) -> None:
140
+ """max_per_sector calculation for 30 stocks at 25%."""
141
+ # 25% of 30 = 7.5 -> 7 (int)
142
+ model = self._create_plan_model(target_position_count=30, max_sector_percentage=0.25)
143
+ plan = model_to_plan(model)
144
+ assert plan.portfolio_config.max_per_sector == 7
145
+
146
+ def test_model_to_plan_config_calculates_max_per_sector_40_percent(self) -> None:
147
+ """max_per_sector calculation for 40% sector limit."""
148
+ # 40% of 20 = 8
149
+ model = self._create_plan_model(target_position_count=20, max_sector_percentage=0.40)
150
+ plan = model_to_plan(model)
151
+ assert plan.portfolio_config.max_per_sector == 8
152
+
153
+ def test_model_to_plan_config_minimum_max_per_sector(self) -> None:
154
+ """max_per_sector should be at least 1."""
155
+ # 5% of 10 = 0.5 -> should be clamped to 1
156
+ model = self._create_plan_model(target_position_count=10, max_sector_percentage=0.05)
157
+ plan = model_to_plan(model)
158
+ assert plan.portfolio_config.max_per_sector == 1
159
+
160
+ def test_model_to_plan_config_default_penalty(self) -> None:
161
+ """model_to_plan should set default unselected_penalty of 10000."""
162
+ model = self._create_plan_model()
163
+ plan = model_to_plan(model)
164
+ assert plan.portfolio_config.unselected_penalty == 10000
165
+
166
+
167
+ class TestPortfolioConfigInDemoData:
168
+ """Tests for PortfolioConfig creation in generate_demo_data()."""
169
+
170
+ def test_small_demo_creates_config(self) -> None:
171
+ """generate_demo_data(SMALL) should create a PortfolioConfig."""
172
+ plan = generate_demo_data(DemoData.SMALL)
173
+ assert plan.portfolio_config is not None
174
+ assert isinstance(plan.portfolio_config, PortfolioConfig)
175
+
176
+ def test_large_demo_creates_config(self) -> None:
177
+ """generate_demo_data(LARGE) should create a PortfolioConfig."""
178
+ plan = generate_demo_data(DemoData.LARGE)
179
+ assert plan.portfolio_config is not None
180
+ assert isinstance(plan.portfolio_config, PortfolioConfig)
181
+
182
+ def test_small_demo_config_values(self) -> None:
183
+ """SMALL demo should have default config values (20 target, 5 max per sector)."""
184
+ plan = generate_demo_data(DemoData.SMALL)
185
+ assert plan.portfolio_config.target_count == 20
186
+ assert plan.portfolio_config.max_per_sector == 5
187
+ assert plan.portfolio_config.unselected_penalty == 10000
188
+
189
+ def test_large_demo_config_values(self) -> None:
190
+ """LARGE demo should have default config values (20 target, 5 max per sector)."""
191
+ plan = generate_demo_data(DemoData.LARGE)
192
+ assert plan.portfolio_config.target_count == 20
193
+ assert plan.portfolio_config.max_per_sector == 5
194
+ assert plan.portfolio_config.unselected_penalty == 10000
195
+
196
+ def test_demo_config_matches_plan_fields(self) -> None:
197
+ """PortfolioConfig values should match plan's target_position_count."""
198
+ plan = generate_demo_data(DemoData.SMALL)
199
+ assert plan.portfolio_config.target_count == plan.target_position_count
200
+
201
+ def test_demo_config_max_per_sector_matches_percentage(self) -> None:
202
+ """max_per_sector should equal max_sector_percentage * target_position_count."""
203
+ plan = generate_demo_data(DemoData.SMALL)
204
+ expected = int(plan.max_sector_percentage * plan.target_position_count)
205
+ assert plan.portfolio_config.max_per_sector == expected
206
+
207
+
208
+ class TestPortfolioConfigEdgeCases:
209
+ """Edge case tests for PortfolioConfig."""
210
+
211
+ def test_very_small_target(self) -> None:
212
+ """PortfolioConfig should work with small target count."""
213
+ config = PortfolioConfig(target_count=5, max_per_sector=2, unselected_penalty=10000)
214
+ assert config.target_count == 5
215
+ assert config.max_per_sector == 2
216
+
217
+ def test_very_large_target(self) -> None:
218
+ """PortfolioConfig should work with large target count."""
219
+ config = PortfolioConfig(target_count=100, max_per_sector=25, unselected_penalty=10000)
220
+ assert config.target_count == 100
221
+ assert config.max_per_sector == 25
222
+
223
+ def test_zero_penalty(self) -> None:
224
+ """PortfolioConfig should allow zero penalty (disables selection driving)."""
225
+ config = PortfolioConfig(target_count=20, max_per_sector=5, unselected_penalty=0)
226
+ assert config.unselected_penalty == 0
227
+
228
+ def test_large_penalty(self) -> None:
229
+ """PortfolioConfig should allow large penalties."""
230
+ config = PortfolioConfig(target_count=20, max_per_sector=5, unselected_penalty=1000000)
231
+ assert config.unselected_penalty == 1000000
232
+
233
+ def test_equality_with_non_config(self) -> None:
234
+ """PortfolioConfig should not equal non-PortfolioConfig objects."""
235
+ config = PortfolioConfig()
236
+ assert config != "not a config"
237
+ assert config != 20
238
+ assert config != {"target_count": 20}
239
+
240
+ def test_repr(self) -> None:
241
+ """PortfolioConfig should have a useful repr."""
242
+ config = PortfolioConfig(target_count=15, max_per_sector=4, unselected_penalty=8000)
243
+ repr_str = repr(config)
244
+ assert "15" in repr_str
245
+ assert "4" in repr_str
246
+ assert "8000" in repr_str
tests/test_rest_api.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for REST API endpoints.
3
+
4
+ Tests that configuration is properly received and applied.
5
+ """
6
+ import pytest
7
+ from fastapi.testclient import TestClient
8
+ from portfolio_optimization.rest_api import app
9
+ from portfolio_optimization.domain import (
10
+ PortfolioOptimizationPlanModel,
11
+ StockSelectionModel,
12
+ SolverConfigModel,
13
+ )
14
+
15
+
16
+ @pytest.fixture
17
+ def client():
18
+ """Create a test client for the FastAPI app."""
19
+ return TestClient(app)
20
+
21
+
22
+ class TestDemoDataEndpoints:
23
+ """Tests for demo data endpoints."""
24
+
25
+ def test_list_demo_data(self, client):
26
+ """GET /demo-data should return available datasets."""
27
+ response = client.get("/demo-data")
28
+ assert response.status_code == 200
29
+ data = response.json()
30
+ assert "SMALL" in data
31
+ assert "LARGE" in data
32
+
33
+ def test_get_small_demo_data(self, client):
34
+ """GET /demo-data/SMALL should return 25 stocks."""
35
+ response = client.get("/demo-data/SMALL")
36
+ assert response.status_code == 200
37
+ data = response.json()
38
+ assert "stocks" in data
39
+ assert len(data["stocks"]) == 25
40
+
41
+ def test_get_large_demo_data(self, client):
42
+ """GET /demo-data/LARGE should return 51 stocks."""
43
+ response = client.get("/demo-data/LARGE")
44
+ assert response.status_code == 200
45
+ data = response.json()
46
+ assert "stocks" in data
47
+ assert len(data["stocks"]) == 51
48
+
49
+
50
+ class TestSolverConfigEndpoints:
51
+ """Tests for solver configuration handling."""
52
+
53
+ def test_plan_model_accepts_solver_config(self):
54
+ """PortfolioOptimizationPlanModel should accept solverConfig."""
55
+ model = PortfolioOptimizationPlanModel(
56
+ stocks=[
57
+ StockSelectionModel(
58
+ stockId="AAPL",
59
+ stockName="Apple",
60
+ sector="Technology",
61
+ predictedReturn=0.12,
62
+ selected=None
63
+ )
64
+ ],
65
+ targetPositionCount=20,
66
+ maxSectorPercentage=0.25,
67
+ solverConfig=SolverConfigModel(terminationSeconds=60)
68
+ )
69
+ assert model.solver_config is not None
70
+ assert model.solver_config.termination_seconds == 60
71
+
72
+ def test_plan_model_serializes_solver_config(self):
73
+ """solverConfig should serialize with camelCase aliases."""
74
+ model = PortfolioOptimizationPlanModel(
75
+ stocks=[],
76
+ solverConfig=SolverConfigModel(terminationSeconds=90)
77
+ )
78
+ data = model.model_dump(by_alias=True)
79
+ assert "solverConfig" in data
80
+ assert data["solverConfig"]["terminationSeconds"] == 90
81
+
82
+ def test_plan_model_deserializes_solver_config(self):
83
+ """solverConfig should deserialize from JSON."""
84
+ json_data = {
85
+ "stocks": [
86
+ {
87
+ "stockId": "AAPL",
88
+ "stockName": "Apple",
89
+ "sector": "Technology",
90
+ "predictedReturn": 0.12,
91
+ "selected": None
92
+ }
93
+ ],
94
+ "targetPositionCount": 15,
95
+ "maxSectorPercentage": 0.30,
96
+ "solverConfig": {
97
+ "terminationSeconds": 120
98
+ }
99
+ }
100
+ model = PortfolioOptimizationPlanModel.model_validate(json_data)
101
+ assert model.target_position_count == 15
102
+ assert model.max_sector_percentage == 0.30
103
+ assert model.solver_config is not None
104
+ assert model.solver_config.termination_seconds == 120
105
+
106
+ def test_plan_without_solver_config(self):
107
+ """Plan should work without solverConfig (uses defaults)."""
108
+ json_data = {
109
+ "stocks": [],
110
+ "targetPositionCount": 20,
111
+ "maxSectorPercentage": 0.25
112
+ }
113
+ model = PortfolioOptimizationPlanModel.model_validate(json_data)
114
+ assert model.solver_config is None # None is OK, will use default 30s
115
+
116
+ def test_post_portfolio_with_solver_config(self, client):
117
+ """POST /portfolios should accept solverConfig in request body."""
118
+ # First get demo data
119
+ demo_response = client.get("/demo-data/SMALL")
120
+ plan_data = demo_response.json()
121
+
122
+ # Add solver config
123
+ plan_data["solverConfig"] = {
124
+ "terminationSeconds": 10 # Use short time for test
125
+ }
126
+
127
+ # Submit for solving
128
+ response = client.post("/portfolios", json=plan_data)
129
+ assert response.status_code == 200
130
+ job_id = response.json()
131
+ assert job_id is not None
132
+ assert len(job_id) > 0
133
+
134
+ # Stop solving immediately (we just want to verify config was accepted)
135
+ stop_response = client.delete(f"/portfolios/{job_id}")
136
+ assert stop_response.status_code == 200
tests/test_solver_config.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for solver configuration functionality.
3
+
4
+ Tests the create_solver_config factory function and dynamic termination time.
5
+ """
6
+ import pytest
7
+ from portfolio_optimization.solver import create_solver_config
8
+ from portfolio_optimization.domain import SolverConfigModel
9
+
10
+
11
+ class TestCreateSolverConfig:
12
+ """Tests for the create_solver_config factory function."""
13
+
14
+ def test_default_termination(self):
15
+ """Default solver should terminate after 30 seconds."""
16
+ config = create_solver_config()
17
+ assert config.termination_config.spent_limit.seconds == 30
18
+
19
+ def test_custom_termination_60s(self):
20
+ """Custom termination time of 60 seconds should be respected."""
21
+ config = create_solver_config(termination_seconds=60)
22
+ assert config.termination_config.spent_limit.seconds == 60
23
+
24
+ def test_custom_termination_10s(self):
25
+ """Minimum termination time of 10 seconds should work."""
26
+ config = create_solver_config(termination_seconds=10)
27
+ assert config.termination_config.spent_limit.seconds == 10
28
+
29
+ def test_custom_termination_300s(self):
30
+ """Maximum termination time of 300 seconds (5 min) should work."""
31
+ config = create_solver_config(termination_seconds=300)
32
+ assert config.termination_config.spent_limit.seconds == 300
33
+
34
+ def test_solver_config_has_correct_solution_class(self):
35
+ """Solver config should reference PortfolioOptimizationPlan."""
36
+ from portfolio_optimization.domain import PortfolioOptimizationPlan
37
+ config = create_solver_config()
38
+ assert config.solution_class == PortfolioOptimizationPlan
39
+
40
+ def test_solver_config_has_correct_entity_class(self):
41
+ """Solver config should include StockSelection entity."""
42
+ from portfolio_optimization.domain import StockSelection
43
+ config = create_solver_config()
44
+ assert StockSelection in config.entity_class_list
45
+
46
+
47
+ class TestSolverConfigModel:
48
+ """Tests for the SolverConfigModel Pydantic model."""
49
+
50
+ def test_default_values(self):
51
+ """SolverConfigModel should have default termination of 30 seconds."""
52
+ model = SolverConfigModel()
53
+ assert model.termination_seconds == 30
54
+
55
+ def test_custom_termination(self):
56
+ """SolverConfigModel should accept custom termination."""
57
+ model = SolverConfigModel(termination_seconds=60)
58
+ assert model.termination_seconds == 60
59
+
60
+ def test_alias_serialization(self):
61
+ """SolverConfigModel should serialize with camelCase alias."""
62
+ model = SolverConfigModel(termination_seconds=45)
63
+ data = model.model_dump(by_alias=True)
64
+ assert "terminationSeconds" in data
65
+ assert data["terminationSeconds"] == 45
66
+
67
+ def test_alias_deserialization(self):
68
+ """SolverConfigModel should deserialize from camelCase."""
69
+ model = SolverConfigModel.model_validate({"terminationSeconds": 90})
70
+ assert model.termination_seconds == 90
71
+
72
+ def test_minimum_validation(self):
73
+ """SolverConfigModel should reject termination < 10 seconds."""
74
+ with pytest.raises(ValueError):
75
+ SolverConfigModel(termination_seconds=5)
76
+
77
+ def test_maximum_validation(self):
78
+ """SolverConfigModel should reject termination > 300 seconds."""
79
+ with pytest.raises(ValueError):
80
+ SolverConfigModel(termination_seconds=400)
81
+
82
+ def test_boundary_min(self):
83
+ """SolverConfigModel should accept exactly 10 seconds."""
84
+ model = SolverConfigModel(termination_seconds=10)
85
+ assert model.termination_seconds == 10
86
+
87
+ def test_boundary_max(self):
88
+ """SolverConfigModel should accept exactly 300 seconds."""
89
+ model = SolverConfigModel(termination_seconds=300)
90
+ assert model.termination_seconds == 300