Skip to content
Merged
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
30 changes: 30 additions & 0 deletions .github/workflows/portfolio-pgd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Portfolio PGD experiment

on:
pull_request:
paths:
- "baseline/experiments/portfolio_pgd/**"
- ".github/workflows/portfolio-pgd.yml"
push:
branches: [main]
paths:
- "baseline/experiments/portfolio_pgd/**"
- ".github/workflows/portfolio-pgd.yml"

jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
cache-dependency-path: baseline/experiments/portfolio_pgd/pyproject.toml
- name: Install experiment
run: python -m pip install -e './baseline/experiments/portfolio_pgd[notebook]'
- name: Run tests
run: python baseline/experiments/portfolio_pgd/tests/run_tests.py
- name: Execute notebook cells
run: python baseline/experiments/portfolio_pgd/scripts/execute_notebooks.py
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,20 @@ correspondence without overloading the Marchenko–Pastur aspect-ratio symbol
`baseline/` contains no RG intervention. Each optimizer package remains an
independent experiment that must be evaluated against the same frozen reference
suite and its own README.

## Portfolio projected-gradient experiment

The independent portfolio-construction experiment is in
[`baseline/experiments/portfolio_pgd`](baseline/experiments/portfolio_pgd). It validates projected
gradient descent against exact KKT and SciPy reference solutions, then exercises nonlinear impact,
factor and sector exposure controls, box constraints, turnover, and gross exposure.

Mac setup and the complete foreground campaign use literal `/tmp`:

```bash
bash baseline/experiments/portfolio_pgd/scripts/setup_mac.sh
bash baseline/experiments/portfolio_pgd/scripts/run_portfolio_pgd_experiment.sh
```

The campaign streams timestamped optimization progress to the terminal and saves the same log plus
CSV/JSON metrics under `/tmp/portfolio-pgd-runs`.
9 changes: 9 additions & 0 deletions baseline/experiments/portfolio_pgd/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
.ipynb_checkpoints/
build/
dist/
*.egg-info/
21 changes: 21 additions & 0 deletions baseline/experiments/portfolio_pgd/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 Calculation Consulting

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
178 changes: 178 additions & 0 deletions baseline/experiments/portfolio_pgd/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# Portfolio PGD Optimizer Experiment

A tested projected-gradient implementation for the portfolio-construction models in
*Portfolio Construction and Information Flow: Transaction Costs, Constraints, and Forecast
Persistence*.

This experiment lives inside `CalculatedContent/rg_optimizers` at
`baseline/experiments/portfolio_pgd`. Runtime environments, logs, metrics, and generated outputs
are kept under literal `/tmp` on the Mac.

## Complete Mac run from a fresh checkout

```bash
cd /tmp
git clone https://github.com/CalculatedContent/rg_optimizers.git
cd /tmp/rg_optimizers

bash baseline/experiments/portfolio_pgd/scripts/setup_mac.sh
bash baseline/experiments/portfolio_pgd/scripts/run_portfolio_pgd_experiment.sh
```

The foreground campaign runner prints every stage and command, streams PGD progress records with
objective, projected-gradient norm, step size, constraint violation, and elapsed time, and saves the
same terminal log under:

```text
/tmp/portfolio-pgd-runs/<run-id>/run.log
```

Machine-readable output is written beside it under `metrics/`: per-scenario histories, holdings,
trades, `summary.csv`, and `summary.json`. The runner never backgrounds, detaches, replaces the
calling shell, or sends process-killing signals.

The package solves

\[
\min_h\;
\frac{\lambda}{2}h^\top Vh-\alpha^\top h
+\frac{\theta}{2}(h-h_-)^\top Q(h-h_-)
+c(h-h_-)
\]

over an intersection of convex portfolio constraints. Here, \(h_-\) is the pre-trade portfolio and
\(c\) may be a smooth square-root/power-law or smoothed bid-ask cost.

## What is included

- Projected gradient descent with a majorization line search and convergence diagnostics.
- Exact KKT solver for the equality-constrained quadratic case.
- Independent SciPy SLSQP benchmark solver. Turnover and gross exposure use exact lifted linear
formulations rather than nonsmooth finite differences.
- Dykstra projection over analytic projectors for:
- full-investment, factor-neutrality, and other affine equalities;
- sector, industry, and other linear exposure bounds;
- long-only/short and per-name bounds;
- hard \(L_1\) turnover;
- hard gross exposure.
- Convex power-law impact and smoothed bid-ask transaction costs.
- Ten automated tests covering gradients, progress callbacks, exact quadratic solutions, nonlinear costs, and
realistic institutional constraint intersections.
- Three detailed Jupyter notebooks.

## Standalone installation

Python 3.10 or later is required.

```bash
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
python -m pip install --upgrade pip
python -m pip install -e ".[notebook]"
```

The optimizer itself requires only NumPy and SciPy. The `notebook` extra adds JupyterLab,
Matplotlib, and pandas.

## Run the tests

The tests use the standard-library `unittest` runner, so pytest is not required:

```bash
python tests/run_tests.py
```

or:

```bash
python -m unittest discover -s tests -p "test_*.py" -v
```

## Open the notebooks

```bash
jupyter notebook notebooks
```

Run them in order:

1. `01_quadratic_pgd_vs_standard_solvers.ipynb`
2. `02_nonlinear_transaction_costs.ipynb`
3. `03_realistic_constraints.ipynb`

Each notebook contains numerical assertions and can be run from a fresh kernel.

## Minimal example

```python
import numpy as np
from portfolio_pgd import ConstraintSet, PGDOptions, PortfolioProblem, solve_pgd

n = 20
rng = np.random.default_rng(7)
raw = rng.normal(size=(n, n))
covariance = raw @ raw.T / n + 0.1 * np.eye(n)

problem = PortfolioProblem(
alpha=rng.normal(scale=0.02, size=n),
covariance=covariance,
previous_holdings=np.full(n, 1.0 / n),
risk_aversion=2.0,
quadratic_cost_matrix=np.ones(n),
quadratic_cost_aversion=0.5,
)

constraints = ConstraintSet(
n,
equality_matrix=np.ones((1, n)),
equality_target=np.array([1.0]),
lower_bounds=0.0,
upper_bounds=0.10,
turnover_limit=0.25,
turnover_center=problem.previous_holdings,
)

result = solve_pgd(problem, constraints, options=PGDOptions(tolerance=1e-8))
print(result.status, result.utility, result.max_constraint_violation)
```

## Solver conventions

- The implementation **minimizes negative utility**. `result.utility` is the economically familiar
maximized quantity; `result.objective` is its negative.
- All matrices use the holdings convention \(A_{eq}h=b_{eq}\) and
\(A_{ub}h\le b_{ub}\).
- Turnover is two-way turnover \(\lVert h-h_-\rVert_1\). Divide by two externally if your reporting
convention defines one-way turnover.
- Power-law costs with \(1<p<2\) have singular curvature at zero. For numerical work, set a small
positive `epsilon`; the notebooks use `1e-3`.
- Cardinality, minimum lots, fixed ticket costs, and integer positions are nonconvex and are outside
this package's global-convergence guarantees.

## Numerical method

For the quadratic case, the Hessian is

\[
H=\lambda V+\theta Q\succ0,
\]

so the solution is unique. The package validates PGD against both the exact KKT system and SciPy's
SLSQP implementation. For nonlinear smooth convex costs, PGD uses the projected-majorization test

\[
F(h^+)\le F(h)+\nabla F(h)^\top(h^+-h)+\frac{\lVert h^+-h\rVert^2}{2\eta}
\]

to backtrack to a safe step.

Projection onto a constraint intersection is performed by Dykstra's algorithm. Every constituent
set has an analytic Euclidean projector; failure to reach the requested feasibility tolerance raises
`ProjectionError` instead of silently returning an infeasible portfolio.

## Scope

This is a research-quality reference implementation intended for reproducible experiments and as a
clear foundation for a production service. A production deployment should additionally integrate
data validation, sparse matrix representations, warm starts across rebalance dates, monitoring,
and organization-specific trading/risk controls.
40 changes: 40 additions & 0 deletions baseline/experiments/portfolio_pgd/VALIDATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Validation record

Validated on 2026-08-21 with Python 3 and the declared NumPy/SciPy runtime dependencies.

## Automated tests

Command:

```bash
python tests/run_tests.py
```

Result: **10 tests passed**.

Coverage includes:

- analytic objective and nonlinear-cost gradients versus centered finite differences;
- live progress-callback initial/final state and final-result consistency;
- PGD holdings and objective versus the exact quadratic KKT solution;
- exact KKT versus SciPy SLSQP;
- nonlinear power-law PGD versus SciPy SLSQP;
- Dykstra projection feasibility and idempotence;
- long-only, caps, affine equalities, sector inequalities, hard turnover;
- long-short dollar/beta neutrality, box bounds, and gross exposure.

## Notebook execution

Command:

```bash
python scripts/execute_notebooks.py
```

Result: every code cell and every embedded numerical assertion passed in all three notebooks.

## Packaging smoke test

The project was built as a PEP 517 wheel with local build dependencies, installed into a clean
temporary target, imported from that installed target, and used to run `examples/quick_start.py`.
The example converged with constraint violation below `1e-9`.
51 changes: 51 additions & 0 deletions baseline/experiments/portfolio_pgd/examples/quick_start.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Run a constrained portfolio optimization and print audit diagnostics."""

from __future__ import annotations

import numpy as np

from portfolio_pgd import (
ConstraintSet,
PGDOptions,
PortfolioProblem,
PowerLawCost,
capped_long_only_portfolio,
factor_covariance,
solve_pgd,
)


def main() -> None:
n = 30
covariance, _ = factor_covariance(n, 4, seed=700)
rng = np.random.default_rng(701)
previous = capped_long_only_portfolio(n, cap=0.06, seed=702)
problem = PortfolioProblem(
alpha=rng.normal(scale=0.025, size=n),
covariance=covariance,
previous_holdings=previous,
risk_aversion=2.0,
quadratic_cost_matrix=0.3 + rng.random(n),
quadratic_cost_aversion=0.4,
nonlinear_cost=PowerLawCost(eta=0.01, p=1.5, epsilon=1.0e-3),
)
constraints = ConstraintSet(
n,
equality_matrix=np.ones((1, n)),
equality_target=np.array([1.0]),
lower_bounds=0.0,
upper_bounds=0.075,
turnover_limit=0.20,
turnover_center=previous,
)
result = solve_pgd(problem, constraints, options=PGDOptions(tolerance=1.0e-8))
print(f"status: {result.status}")
print(f"iterations: {result.iterations}")
print(f"utility: {result.utility:.10f}")
print(f"two-way turnover: {np.sum(np.abs(result.trades)):.10f}")
print(f"projected-gradient norm: {result.projected_gradient_norm:.3e}")
print(f"constraint violation: {result.max_constraint_violation:.3e}")


if __name__ == "__main__":
main()
Loading
Loading