Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
```
8 changes: 8 additions & 0 deletions requirements-web.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
fastapi
uvicorn
pandas
numpy
python-multipart
pydantic
qpsolvers
scipy
77 changes: 77 additions & 0 deletions src/api_core.py
Original file line number Diff line number Diff line change
@@ -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))),
},
}
49 changes: 49 additions & 0 deletions src/fastapi_app.py
Original file line number Diff line number Diff line change
@@ -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