| | """
|
| | Portfolio Optimization Domain Model
|
| |
|
| | This module defines the core domain entities for stock portfolio optimization:
|
| | - StockSelection: A stock that can be selected for the portfolio (planning entity)
|
| | - PortfolioOptimizationPlan: The complete portfolio optimization problem (planning solution)
|
| |
|
| | The model uses a Boolean selection approach:
|
| | - Each stock has a `selected` field (True/False)
|
| | - Selected stocks get equal weight (100% / number_selected)
|
| | - This simplifies the optimization while still demonstrating constraint solving
|
| | """
|
| | from solverforge_legacy.solver import SolverStatus
|
| | from solverforge_legacy.solver.domain import (
|
| | planning_entity,
|
| | planning_solution,
|
| | PlanningId,
|
| | PlanningVariable,
|
| | PlanningEntityCollectionProperty,
|
| | ProblemFactCollectionProperty,
|
| | ProblemFactProperty,
|
| | ValueRangeProvider,
|
| | PlanningScore,
|
| | )
|
| | from solverforge_legacy.solver.score import HardSoftScore
|
| | from typing import Annotated, List, Optional
|
| | from dataclasses import dataclass, field
|
| | from .json_serialization import JsonDomainBase
|
| | from pydantic import Field
|
| |
|
| |
|
| | @dataclass
|
| | class SelectionValue:
|
| | """
|
| | Represents a possible selection state for a stock.
|
| |
|
| | We use this wrapper class instead of raw bool because SolverForge
|
| | needs a reference type for the value range provider.
|
| | """
|
| | value: bool
|
| |
|
| | def __hash__(self):
|
| | return hash(self.value)
|
| |
|
| | def __eq__(self, other):
|
| | if isinstance(other, SelectionValue):
|
| | return self.value == other.value
|
| | return False
|
| |
|
| |
|
| |
|
| | SELECTED = SelectionValue(True)
|
| | NOT_SELECTED = SelectionValue(False)
|
| |
|
| |
|
| | @dataclass
|
| | class PortfolioConfig:
|
| | """
|
| | Configuration parameters for portfolio constraints.
|
| |
|
| | This is a problem fact that constraints can join against to access
|
| | configurable threshold values.
|
| |
|
| | Attributes:
|
| | target_count: Number of stocks to select (default 20)
|
| | max_per_sector: Maximum stocks per sector (default 5, which is 25% of 20)
|
| | unselected_penalty: Soft penalty per unselected stock (default 10000)
|
| | """
|
| | target_count: int = 20
|
| | max_per_sector: int = 5
|
| | unselected_penalty: int = 10000
|
| |
|
| | def __hash__(self) -> int:
|
| | return hash((self.target_count, self.max_per_sector, self.unselected_penalty))
|
| |
|
| | def __eq__(self, other: object) -> bool:
|
| | if isinstance(other, PortfolioConfig):
|
| | return (
|
| | self.target_count == other.target_count
|
| | and self.max_per_sector == other.max_per_sector
|
| | and self.unselected_penalty == other.unselected_penalty
|
| | )
|
| | return False
|
| |
|
| |
|
| | @planning_entity
|
| | @dataclass
|
| | class StockSelection:
|
| | """
|
| | Represents a stock that can be included in the portfolio.
|
| |
|
| | This is a planning entity - SolverForge decides whether to include
|
| | each stock by setting the `selection` field.
|
| |
|
| | Attributes:
|
| | stock_id: Unique identifier (ticker symbol, e.g., "AAPL")
|
| | stock_name: Human-readable name (e.g., "Apple Inc.")
|
| | sector: Industry sector (e.g., "Technology", "Healthcare")
|
| | predicted_return: ML-predicted return as decimal (0.12 = 12%)
|
| | selection: Planning variable - SELECTED or NOT_SELECTED
|
| | """
|
| | stock_id: Annotated[str, PlanningId]
|
| | stock_name: str
|
| | sector: str
|
| | predicted_return: float
|
| |
|
| |
|
| |
|
| |
|
| | selection: Annotated[
|
| | SelectionValue | None,
|
| | PlanningVariable(value_range_provider_refs=["selection_range"])
|
| | ] = None
|
| |
|
| | @property
|
| | def selected(self) -> bool | None:
|
| | """Convenience property to check if stock is selected."""
|
| | if self.selection is None:
|
| | return None
|
| | return self.selection.value
|
| |
|
| |
|
| | @planning_solution
|
| | @dataclass
|
| | class PortfolioOptimizationPlan:
|
| | """
|
| | The complete portfolio optimization problem.
|
| |
|
| | This is the planning solution that contains:
|
| | - All candidate stocks (planning entities)
|
| | - Configuration parameters
|
| | - The optimization score
|
| |
|
| | The solver will decide which stocks to select (set selected=True)
|
| | while respecting constraints and maximizing expected return.
|
| | """
|
| |
|
| | stocks: Annotated[
|
| | list[StockSelection],
|
| | PlanningEntityCollectionProperty,
|
| | ValueRangeProvider
|
| | ]
|
| |
|
| |
|
| | target_position_count: int = 20
|
| | max_sector_percentage: float = 0.25
|
| |
|
| |
|
| |
|
| | portfolio_config: Annotated[
|
| | PortfolioConfig,
|
| | ProblemFactProperty
|
| | ] = field(default_factory=PortfolioConfig)
|
| |
|
| |
|
| |
|
| |
|
| | selection_range: Annotated[
|
| | list[SelectionValue],
|
| | ValueRangeProvider(id="selection_range"),
|
| | ProblemFactCollectionProperty
|
| | ] = field(default_factory=lambda: [SELECTED, NOT_SELECTED])
|
| |
|
| |
|
| | score: Annotated[HardSoftScore | None, PlanningScore] = None
|
| |
|
| |
|
| | solver_status: SolverStatus = SolverStatus.NOT_SOLVING
|
| |
|
| | def get_selected_stocks(self) -> list[StockSelection]:
|
| | """Return only stocks that are selected for the portfolio."""
|
| | return [s for s in self.stocks if s.selected is True]
|
| |
|
| | def get_selected_count(self) -> int:
|
| | """Return count of selected stocks."""
|
| | return len(self.get_selected_stocks())
|
| |
|
| | def get_weight_per_stock(self) -> float:
|
| | """Calculate equal weight per selected stock (e.g., 20 stocks = 5% each)."""
|
| | count = self.get_selected_count()
|
| | return 1.0 / count if count > 0 else 0.0
|
| |
|
| | def get_sector_weights(self) -> dict[str, float]:
|
| | """Calculate total weight per sector."""
|
| | weight = self.get_weight_per_stock()
|
| | sector_weights: dict[str, float] = {}
|
| | for stock in self.get_selected_stocks():
|
| | sector_weights[stock.sector] = sector_weights.get(stock.sector, 0.0) + weight
|
| | return sector_weights
|
| |
|
| | def get_expected_return(self) -> float:
|
| | """Calculate total expected portfolio return."""
|
| | weight = self.get_weight_per_stock()
|
| | return sum(s.predicted_return * weight for s in self.get_selected_stocks())
|
| |
|
| | def get_herfindahl_index(self) -> float:
|
| | """
|
| | Calculate the Herfindahl-Hirschman Index (HHI) for sector concentration.
|
| |
|
| | HHI = sum of (sector_weight)^2
|
| | - Range: 1/n (perfectly diversified) to 1.0 (all in one sector)
|
| | - Lower HHI = more diversified
|
| | - Common thresholds: <0.15 (diversified), 0.15-0.25 (moderate), >0.25 (concentrated)
|
| | """
|
| | sector_weights = self.get_sector_weights()
|
| | if not sector_weights:
|
| | return 0.0
|
| | return sum(w * w for w in sector_weights.values())
|
| |
|
| | def get_diversification_score(self) -> float:
|
| | """
|
| | Calculate diversification score as 1 - HHI.
|
| |
|
| | Range: 0.0 (all in one sector) to 1-1/n (perfectly diversified)
|
| | Higher = more diversified
|
| | """
|
| | return 1.0 - self.get_herfindahl_index()
|
| |
|
| | def get_max_sector_exposure(self) -> float:
|
| | """
|
| | Get the highest single sector weight.
|
| |
|
| | Returns the weight of the most concentrated sector.
|
| | Lower is better for diversification.
|
| | """
|
| | sector_weights = self.get_sector_weights()
|
| | if not sector_weights:
|
| | return 0.0
|
| | return max(sector_weights.values())
|
| |
|
| | def get_sector_count(self) -> int:
|
| | """Return count of unique sectors in selected stocks."""
|
| | selected = self.get_selected_stocks()
|
| | return len(set(s.sector for s in selected))
|
| |
|
| | def get_return_volatility(self) -> float:
|
| | """
|
| | Calculate standard deviation of predicted returns (proxy for risk).
|
| |
|
| | Higher volatility = higher risk portfolio.
|
| | """
|
| | selected = self.get_selected_stocks()
|
| | if len(selected) < 2:
|
| | return 0.0
|
| |
|
| | returns = [s.predicted_return for s in selected]
|
| | mean_return = sum(returns) / len(returns)
|
| | variance = sum((r - mean_return) ** 2 for r in returns) / len(returns)
|
| | return variance ** 0.5
|
| |
|
| | def get_sharpe_proxy(self) -> float:
|
| | """
|
| | Calculate a proxy for Sharpe ratio: return / volatility.
|
| |
|
| | Higher = better risk-adjusted return.
|
| | Note: This is a simplified proxy, not true Sharpe (no risk-free rate).
|
| | """
|
| | volatility = self.get_return_volatility()
|
| | if volatility == 0:
|
| | return 0.0
|
| | return self.get_expected_return() / volatility
|
| |
|
| |
|
| |
|
| |
|
| |
|
| |
|
| | class StockSelectionModel(JsonDomainBase):
|
| | """REST API model for StockSelection."""
|
| | stock_id: str = Field(..., alias="stockId")
|
| | stock_name: str = Field(..., alias="stockName")
|
| | sector: str
|
| | predicted_return: float = Field(..., alias="predictedReturn")
|
| | selected: Optional[bool] = None
|
| |
|
| |
|
| | class SolverConfigModel(JsonDomainBase):
|
| | """REST API model for solver configuration options."""
|
| | termination_seconds: int = Field(default=30, alias="terminationSeconds", ge=10, le=300)
|
| |
|
| |
|
| | class PortfolioMetricsModel(JsonDomainBase):
|
| | """
|
| | REST API model for portfolio business metrics (KPIs).
|
| |
|
| | These metrics provide business insight beyond the solver score:
|
| | - Diversification measures (HHI, max sector exposure)
|
| | - Risk/return measures (expected return, volatility, Sharpe proxy)
|
| | """
|
| | expected_return: float = Field(..., alias="expectedReturn")
|
| | sector_count: int = Field(..., alias="sectorCount")
|
| | max_sector_exposure: float = Field(..., alias="maxSectorExposure")
|
| | herfindahl_index: float = Field(..., alias="herfindahlIndex")
|
| | diversification_score: float = Field(..., alias="diversificationScore")
|
| | return_volatility: float = Field(..., alias="returnVolatility")
|
| | sharpe_proxy: float = Field(..., alias="sharpeProxy")
|
| |
|
| |
|
| | class PortfolioOptimizationPlanModel(JsonDomainBase):
|
| | """REST API model for PortfolioOptimizationPlan."""
|
| | stocks: List[StockSelectionModel]
|
| | target_position_count: int = Field(default=20, alias="targetPositionCount")
|
| | max_sector_percentage: float = Field(default=0.25, alias="maxSectorPercentage")
|
| | score: Optional[str] = None
|
| | solver_status: Optional[str] = Field(None, alias="solverStatus")
|
| | solver_config: Optional[SolverConfigModel] = Field(None, alias="solverConfig")
|
| | metrics: Optional[PortfolioMetricsModel] = None
|
| |
|