diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6ade501 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements-web.txt ./ +RUN pip install --no-cache-dir -r requirements-web.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "fastapi_app:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "src"] diff --git a/README.md b/README.md index 4e72dbe..2080a45 100644 --- a/README.md +++ b/README.md @@ -1 +1,47 @@ # PorQua + +PorQua is a Python library for portfolio optimization and backtesting. + +## FastAPI service (new) + +This repository now includes a lightweight FastAPI wrapper around PorQua's +least-squares optimizer so users can upload CSV data and receive optimized +portfolio weights through a REST endpoint. + +### Endpoint + +- `POST /api/v1/optimize` +- multipart form-data fields: + - `data` (required): CSV file containing asset return columns and one benchmark column + - `params` (optional): JSON string with optimization settings + +Example `params`: + +```json +{ + "benchmark_column": "benchmark", + "solver_name": "highs", + "l2_penalty": 0.0, + "constraints": { + "budget": true, + "long_only": true, + "upper_bound": 0.1 + } +} +``` + +The response contains optimized weights and key metrics (`objective_value`, `tracking_error`). + +### Run locally + +```bash +pip install -r requirements-web.txt +PYTHONPATH=src uvicorn fastapi_app:app --host 0.0.0.0 --port 8000 +``` + +### Docker + +```bash +docker build -t porqua-api . +docker run --rm -p 8000:8000 porqua-api +``` diff --git a/requirements-web.txt b/requirements-web.txt new file mode 100644 index 0000000..6909bf9 --- /dev/null +++ b/requirements-web.txt @@ -0,0 +1,8 @@ +fastapi +uvicorn +pandas +numpy +python-multipart +pydantic +qpsolvers +scipy diff --git a/src/api_core.py b/src/api_core.py new file mode 100644 index 0000000..70d835d --- /dev/null +++ b/src/api_core.py @@ -0,0 +1,77 @@ +"""Core utilities for exposing PorQua optimization through web interfaces.""" + +from __future__ import annotations + +from dataclasses import dataclass +import io +from typing import Any + +import numpy as np +import pandas as pd + +from constraints import Constraints +from optimization import LeastSquares +from optimization_data import OptimizationData + + +@dataclass +class OptimizationConfig: + benchmark_column: str = "benchmark" + solver_name: str = "highs" + add_budget: bool = True + long_only: bool = True + upper_bound: float = 1.0 + l2_penalty: float = 0.0 + + +def parse_csv_payload(raw_bytes: bytes) -> pd.DataFrame: + if not raw_bytes: + raise ValueError("Uploaded file is empty.") + dataframe = pd.read_csv(io.BytesIO(raw_bytes)) + if dataframe.empty: + raise ValueError("Uploaded CSV contains no rows.") + return dataframe + + +def optimize_least_squares(dataframe: pd.DataFrame, config: OptimizationConfig) -> dict[str, Any]: + if config.benchmark_column not in dataframe.columns: + raise ValueError(f"Benchmark column '{config.benchmark_column}' not found.") + + numeric_frame = dataframe.apply(pd.to_numeric, errors="coerce") + if numeric_frame.isna().any().any(): + raise ValueError("All columns must contain numeric values.") + + feature_columns = [col for col in numeric_frame.columns if col != config.benchmark_column] + if not feature_columns: + raise ValueError("CSV must include at least one asset column besides the benchmark column.") + + x = numeric_frame[feature_columns] + y = numeric_frame[config.benchmark_column] + + optimization_data = OptimizationData(X=x, y=y, align=True) + + constraints = Constraints(selection=x.columns) + if config.add_budget: + constraints.add_budget() + if config.long_only: + constraints.add_box(box_type="LongOnly", upper=config.upper_bound) + + optimization = LeastSquares(solver_name=config.solver_name, sparse=True) + optimization.params["l2_penalty"] = config.l2_penalty + optimization.constraints = constraints + optimization.set_objective(optimization_data) + optimization.solve() + + if not optimization.results["status"]: + raise RuntimeError("Optimization solver did not find a feasible solution.") + + solution = optimization.model["solution"] + objective_value = optimization.model.objective_value(solution.x, with_const=True) + + return { + "weights": optimization.results["weights"], + "metrics": { + "objective_value": float(objective_value), + "tracking_error": float(np.sqrt(max(objective_value, 0.0))), + }, + } diff --git a/src/fastapi_app.py b/src/fastapi_app.py new file mode 100644 index 0000000..9d1a103 --- /dev/null +++ b/src/fastapi_app.py @@ -0,0 +1,49 @@ +"""FastAPI service exposing PorQua least-squares optimization.""" + +from __future__ import annotations + +import json + +from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from pydantic import BaseModel, Field + +from api_core import OptimizationConfig, optimize_least_squares, parse_csv_payload + + +class ConstraintRequest(BaseModel): + budget: bool = True + long_only: bool = True + upper_bound: float = Field(default=1.0, ge=0.0) + + +class OptimizationRequest(BaseModel): + benchmark_column: str = "benchmark" + solver_name: str = "highs" + l2_penalty: float = 0.0 + constraints: ConstraintRequest = ConstraintRequest() + + +app = FastAPI(title="PorQua API", version="0.1.0") + + +@app.post("/api/v1/optimize") +async def optimize_portfolio( + data: UploadFile = File(...), + params: str | None = Form(default=None), +): + try: + payload = await data.read() + dataframe = parse_csv_payload(payload) + + request = OptimizationRequest(**json.loads(params)) if params else OptimizationRequest() + config = OptimizationConfig( + benchmark_column=request.benchmark_column, + solver_name=request.solver_name, + add_budget=request.constraints.budget, + long_only=request.constraints.long_only, + upper_bound=request.constraints.upper_bound, + l2_penalty=request.l2_penalty, + ) + return optimize_least_squares(dataframe, config) + except (ValueError, RuntimeError, json.JSONDecodeError) as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc