| """
|
| 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
|
|
|