blackopsrepl's picture
.
d9f5c15
"""
Converters between domain objects and REST API models.
These functions handle the transformation between:
- Domain objects (dataclasses used by the solver)
- REST models (Pydantic models used by the API)
"""
from . import domain
from .domain import SELECTED, NOT_SELECTED, PortfolioConfig
def stock_to_model(stock: domain.StockSelection) -> domain.StockSelectionModel:
"""Convert a StockSelection domain object to REST model."""
# Note: Pydantic model has populate_by_name=True, allowing snake_case field names
return domain.StockSelectionModel( # type: ignore[call-arg]
stock_id=stock.stock_id,
stock_name=stock.stock_name,
sector=stock.sector,
predicted_return=stock.predicted_return,
selected=stock.selected, # Uses the @property that returns bool
)
def plan_to_metrics(plan: domain.PortfolioOptimizationPlan) -> domain.PortfolioMetricsModel | None:
"""Calculate business metrics from a plan."""
if plan.get_selected_count() == 0:
return None
return domain.PortfolioMetricsModel( # type: ignore[call-arg]
expected_return=plan.get_expected_return(),
sector_count=plan.get_sector_count(),
max_sector_exposure=plan.get_max_sector_exposure(),
herfindahl_index=plan.get_herfindahl_index(),
diversification_score=plan.get_diversification_score(),
return_volatility=plan.get_return_volatility(),
sharpe_proxy=plan.get_sharpe_proxy(),
)
def plan_to_model(plan: domain.PortfolioOptimizationPlan) -> domain.PortfolioOptimizationPlanModel:
"""Convert a PortfolioOptimizationPlan domain object to REST model."""
# Note: Pydantic model has populate_by_name=True, allowing snake_case field names
return domain.PortfolioOptimizationPlanModel( # type: ignore[call-arg]
stocks=[stock_to_model(s) for s in plan.stocks],
target_position_count=plan.target_position_count,
max_sector_percentage=plan.max_sector_percentage,
score=str(plan.score) if plan.score else None,
solver_status=plan.solver_status.name if plan.solver_status else None,
metrics=plan_to_metrics(plan),
)
def model_to_stock(model: domain.StockSelectionModel) -> domain.StockSelection:
"""Convert a StockSelectionModel REST model to domain object.
Note: The REST model uses `selected: bool` but the domain uses
`selection: SelectionValue`. We convert here.
"""
# Convert bool to SelectionValue (or None if not set)
selection = None
if model.selected is True:
selection = SELECTED
elif model.selected is False:
selection = NOT_SELECTED
# If model.selected is None, leave selection as None
return domain.StockSelection(
stock_id=model.stock_id,
stock_name=model.stock_name,
sector=model.sector,
predicted_return=model.predicted_return,
selection=selection,
)
def model_to_plan(model: domain.PortfolioOptimizationPlanModel) -> domain.PortfolioOptimizationPlan:
"""Convert a PortfolioOptimizationPlanModel REST model to domain object.
Creates a PortfolioConfig from the model's target_position_count and
max_sector_percentage so that constraints can access these values.
"""
stocks = [model_to_stock(s) for s in model.stocks]
# Parse score if provided
score = None
if model.score:
from solverforge_legacy.solver.score import HardSoftScore
score = HardSoftScore.parse(model.score)
# Parse solver status if provided
solver_status = domain.SolverStatus.NOT_SOLVING
if model.solver_status:
solver_status = domain.SolverStatus[model.solver_status]
# Calculate max_per_sector from max_sector_percentage and target_position_count
# Example: 25% of 20 stocks = 5 stocks max per sector
target_count = model.target_position_count
max_per_sector = max(1, int(model.max_sector_percentage * target_count))
# Create PortfolioConfig for constraints to access
portfolio_config = PortfolioConfig(
target_count=target_count,
max_per_sector=max_per_sector,
unselected_penalty=10000, # Default penalty
)
return domain.PortfolioOptimizationPlan(
stocks=stocks,
target_position_count=model.target_position_count,
max_sector_percentage=model.max_sector_percentage,
portfolio_config=portfolio_config,
score=score,
solver_status=solver_status,
)