Commit
·
d9f5c15
0
Parent(s):
- .gitignore +29 -0
- Dockerfile +24 -0
- README.md +11 -0
- logging.conf +30 -0
- pyproject.toml +20 -0
- src/portfolio_optimization/__init__.py +26 -0
- src/portfolio_optimization/constraints.py +232 -0
- src/portfolio_optimization/converters.py +114 -0
- src/portfolio_optimization/demo_data.py +223 -0
- src/portfolio_optimization/domain.py +307 -0
- src/portfolio_optimization/json_serialization.py +42 -0
- src/portfolio_optimization/rest_api.py +189 -0
- src/portfolio_optimization/score_analysis.py +23 -0
- src/portfolio_optimization/solver.py +59 -0
- static/app.js +851 -0
- static/config.js +183 -0
- static/index.html +672 -0
- static/webjars/solverforge/css/solverforge-webui.css +68 -0
- static/webjars/solverforge/img/solverforge-favicon.svg +65 -0
- static/webjars/solverforge/img/solverforge-horizontal-white.svg +66 -0
- static/webjars/solverforge/img/solverforge-horizontal.svg +65 -0
- static/webjars/solverforge/img/solverforge-logo-stacked.svg +73 -0
- static/webjars/solverforge/js/solverforge-webui.js +142 -0
- tests/test_business_metrics.py +352 -0
- tests/test_constraints.py +381 -0
- tests/test_feasible.py +161 -0
- tests/test_portfolio_config.py +246 -0
- tests/test_rest_api.py +136 -0
- tests/test_solver_config.py +90 -0
.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
|