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