| """
|
| REST API for Portfolio Optimization
|
|
|
| This module provides HTTP endpoints for the portfolio optimization quickstart:
|
|
|
| Endpoints:
|
| - GET /demo-data - List available demo datasets
|
| - GET /demo-data/{id} - Load a specific demo dataset
|
| - POST /portfolios - Submit a portfolio for optimization
|
| - GET /portfolios/{id} - Get current solution for a job
|
| - GET /portfolios/{id}/status - Get solving status
|
| - DELETE /portfolios/{id} - Stop solving
|
| - PUT /portfolios/analyze - Analyze a submitted portfolio's score
|
|
|
| The API follows the same patterns as other SolverForge quickstarts.
|
| """
|
| from fastapi import FastAPI, Request
|
| from fastapi.staticfiles import StaticFiles
|
| from uuid import uuid4
|
| from dataclasses import replace
|
| from typing import Any
|
|
|
| from solverforge_legacy.solver import SolverManager, SolverFactory
|
|
|
| from .domain import PortfolioOptimizationPlan, PortfolioOptimizationPlanModel
|
| from .converters import plan_to_model, model_to_plan
|
| from .demo_data import DemoData, generate_demo_data
|
| from .solver import solver_manager, solution_manager, create_solver_config
|
| from .score_analysis import ConstraintAnalysisDTO, MatchAnalysisDTO
|
|
|
|
|
| app = FastAPI(
|
| title="Portfolio Optimization Quickstart",
|
| description="SolverForge quickstart for stock portfolio optimization",
|
| docs_url='/q/swagger-ui'
|
| )
|
|
|
|
|
| data_sets: dict[str, PortfolioOptimizationPlan] = {}
|
| solver_managers: dict[str, SolverManager] = {}
|
|
|
|
|
| @app.get("/demo-data")
|
| async def demo_data_list() -> list[DemoData]:
|
| """List available demo datasets."""
|
| return [e for e in DemoData]
|
|
|
|
|
| @app.get("/demo-data/{dataset_id}", response_model_exclude_none=True)
|
| async def get_demo_data(dataset_id: str) -> PortfolioOptimizationPlanModel:
|
| """Load a specific demo dataset."""
|
| demo_data = getattr(DemoData, dataset_id)
|
| domain_plan = generate_demo_data(demo_data)
|
| return plan_to_model(domain_plan)
|
|
|
|
|
| @app.get("/portfolios/{problem_id}", response_model_exclude_none=True)
|
| async def get_portfolio(problem_id: str) -> PortfolioOptimizationPlanModel:
|
| """Get current solution for a portfolio optimization job."""
|
| plan = data_sets[problem_id]
|
|
|
| manager = solver_managers.get(problem_id, solver_manager)
|
| updated_plan = replace(plan, solver_status=manager.get_solver_status(problem_id))
|
| return plan_to_model(updated_plan)
|
|
|
|
|
| def update_portfolio(problem_id: str, plan: PortfolioOptimizationPlan) -> None:
|
| """Callback to update the stored solution as solver improves it."""
|
| global data_sets
|
| data_sets[problem_id] = plan
|
|
|
|
|
| @app.post("/portfolios")
|
| async def solve_portfolio(plan_model: PortfolioOptimizationPlanModel) -> str:
|
| """
|
| Submit a portfolio for optimization.
|
|
|
| Returns a job ID that can be used to retrieve the solution.
|
| Supports custom solver configuration via solverConfig field.
|
| """
|
| job_id = str(uuid4())
|
| plan = model_to_plan(plan_model)
|
| data_sets[job_id] = plan
|
|
|
|
|
| termination_seconds = 30
|
| if plan_model.solver_config and plan_model.solver_config.termination_seconds:
|
| termination_seconds = plan_model.solver_config.termination_seconds
|
|
|
|
|
| config = create_solver_config(termination_seconds)
|
| manager: SolverManager = SolverManager.create(SolverFactory.create(config))
|
| solver_managers[job_id] = manager
|
|
|
| manager.solve_and_listen(
|
| job_id,
|
| plan,
|
| lambda solution: update_portfolio(job_id, solution)
|
| )
|
| return job_id
|
|
|
|
|
| @app.get("/portfolios")
|
| async def list_portfolios() -> list[str]:
|
| """List all job IDs of submitted portfolios."""
|
| return list(data_sets.keys())
|
|
|
|
|
| @app.get("/portfolios/{problem_id}/status")
|
| async def get_status(problem_id: str) -> dict[str, Any]:
|
| """Get the portfolio status and score for a given job ID."""
|
| if problem_id not in data_sets:
|
| raise ValueError(f"No portfolio found with ID {problem_id}")
|
|
|
| plan = data_sets[problem_id]
|
|
|
| manager = solver_managers.get(problem_id, solver_manager)
|
| solver_status = manager.get_solver_status(problem_id)
|
|
|
|
|
| selected_count = plan.get_selected_count()
|
| expected_return = plan.get_expected_return() if selected_count > 0 else 0
|
|
|
| return {
|
| "score": {
|
| "hardScore": plan.score.hard_score if plan.score else 0,
|
| "softScore": plan.score.soft_score if plan.score else 0,
|
| },
|
| "solverStatus": solver_status.name,
|
| "selectedCount": selected_count,
|
| "expectedReturn": expected_return,
|
| "sectorWeights": plan.get_sector_weights() if selected_count > 0 else {},
|
| }
|
|
|
|
|
| @app.delete("/portfolios/{problem_id}")
|
| async def stop_solving(problem_id: str) -> PortfolioOptimizationPlanModel:
|
| """Terminate solving for a given job ID."""
|
| if problem_id not in data_sets:
|
| raise ValueError(f"No portfolio found with ID {problem_id}")
|
|
|
|
|
| manager = solver_managers.get(problem_id, solver_manager)
|
| try:
|
| manager.terminate_early(problem_id)
|
| except Exception as e:
|
| print(f"Warning: terminate_early failed for {problem_id}: {e}")
|
|
|
| return await get_portfolio(problem_id)
|
|
|
|
|
| @app.put("/portfolios/analyze")
|
| async def analyze_portfolio(request: Request) -> dict[str, Any]:
|
| """Submit a portfolio to analyze its score in detail."""
|
| json_data = await request.json()
|
|
|
|
|
| plan_model = PortfolioOptimizationPlanModel.model_validate(json_data)
|
|
|
|
|
| domain_plan = model_to_plan(plan_model)
|
|
|
| analysis = solution_manager.analyze(domain_plan)
|
|
|
|
|
| constraints = []
|
| for constraint in getattr(analysis, 'constraint_analyses', []) or []:
|
| matches = [
|
| MatchAnalysisDTO(
|
| name=str(getattr(getattr(match, 'constraint_ref', None), 'constraint_name', "")),
|
| score=str(getattr(match, 'score', "0hard/0soft")),
|
| justification=str(getattr(match, 'justification', "")),
|
| )
|
| for match in getattr(constraint, 'matches', []) or []
|
| ]
|
|
|
| constraint_dto = ConstraintAnalysisDTO(
|
| name=str(getattr(constraint, 'constraint_name', "")),
|
| weight=str(getattr(constraint, 'weight', "0hard/0soft")),
|
| score=str(getattr(constraint, 'score', "0hard/0soft")),
|
| matches=matches,
|
| )
|
| constraints.append(constraint_dto)
|
|
|
| return {"constraints": [constraint.model_dump() for constraint in constraints]}
|
|
|
|
|
|
|
| app.mount("/", StaticFiles(directory="static", html=True), name="static")
|
|
|