Portfolio Optimization¶
Problem Statement¶
This example solves a financial portfolio optimization model, using historical return data.
Full example scenario
An investor has historical return data for a set of stocks and wants to build a minimum-risk portfolio, then see how that risk changes as the required return is raised. Which fraction of the budget should go into each stock to minimize portfolio risk, for a given target return?
Step 1: calculate the minimum-risk portfolio.
Step 2: solve for the efficient frontier by varying the target expected return across a range and re-solving for the minimum-risk portfolio at each one.
The portfolio must be fully invested (weights sum to 1) and long-only (no short-selling).
The stock return data can be found in
portfolio.csv, with one column per stock ticker and one row per periodic return observation (as decimal returns, not prices).
What an optimal solution looks like
Minimum-risk portfolio — volatility 1.29%, expected return 0.24%, 7 of 10 stocks held.
Hover or focus a point for its exact volatility and return.
Built with Modeler¶
The problem above is stated as a single shot: you hand over the whole problem and get a model back. Modeler takes the other route — you start with the business question you actually have, and it works with you until the problem is pinned down, the data is in, the code runs, and the answer holds up when the problem changes.
The tabs below walk the same portfolio optimization problem through that route, one Modeler capability at a time.
Instead of writing the whole prompt up front, you start with the business question you actually have. Modeler searches the knowledge base, asks one clarifying question at a time — always the most important one first — and tells you which parts of the model are still missing. The options it offers come from examples in the knowledge base and from how similar problems are usually modeled, so you can answer with a number instead of prose.
I have a bunch of stocks I track, and I want to figure out how to split my money across them to minimize risk. Can you help me build a model for that?
“Minimize risk” isn’t a number yet — this is the classic Markowitz portfolio problem, but “risk” could mean pure variance minimization, a risk/return trade-off, or a floor on return. Let me search the knowledge base for how this is usually modeled, then ask which reading the user actually wants before assuming variance alone.
Querying the knowledge base.
Great question! This is a classic portfolio optimization problem, well-suited for Gurobi. Let me ask you a few questions to nail down the details so we can build a precise specification.
Question 1: What does “minimizing risk” mean to you in this context?
Minimize portfolio variance (or equivalently, volatility) — the classic Markowitz mean-variance approach, which uses the covariance matrix of stock returns to measure risk.
Minimize volatility subject to a minimum expected return — you accept a floor on returns in exchange for the lowest possible risk (this traces the efficient frontier).
Minimize a risk/return trade-off — balance risk and return using a user-defined weight parameter (e.g., maximize return − lambda × variance).
Option 1 — just minimize portfolio variance for now, no target return floor. One more thing: I’d also like to see how the minimum risk changes if I require a range of different target returns, so I can compare the risk/return trade-off across a few scenarios, not just get the single minimum-risk point.
That settles the objective: pure minimum-variance, no return floor. The volunteered frontier-sweep request is a natural extension of the same objective — solve the same minimum-variance problem repeatedly with an added target-return constraint — so it belongs in the specification as a second solve mode, not a separate model. Recording both. Still open: how the money is actually allowed to be allocated (shorting, per-stock limits, sector caps) — asking that next, since it changes the variable bounds rather than just the objective.
Recorded — minimize portfolio variance, with an efficient frontier sweep across target returns as a second output. The knowledge base confirms this is a classic Markowitz minimum-variance model, a great fit for Gurobi’s quadratic programming capabilities.
Question 2: What constraints do you want to place on how the money is allocated?
Fully invested — the weights of all stocks must sum to exactly 100% (all money is allocated).
Long-only — no short-selling allowed, so each stock weight must be between 0 and 1 (100%).
Weight bounds — each stock has a minimum and/or maximum allocation (e.g., no single stock can exceed 30% of the portfolio).
Cardinality constraint — you must hold exactly (or at most) a fixed number of stocks (e.g., pick at most 10 out of 50 stocks).
Sector/group constraints — the combined allocation to stocks in a given sector must stay within a range (e.g., no more than 40% in technology stocks).
Fully invested (weights sum to 1), long-only (no shorting), no other caps or sector limits for now.
Thinking…
Modeler updates the specification after every answer, even while it is still incomplete — so you can always see what it has understood and what it is still waiting on.
Modeler asked for the stock data as a file rather than as ten columns of
numbers typed into the chat. Attach portfolio.csv to the conversation and
it reads the real shape of your data instead of guessing at it — how many
stocks, how many observations, and what units they’re already in.
#,AA,BB,CC,DD,EE,FF,GG,HH,II,JJ
0,-0.000601195612674,0.00235338594971,-0.0752335718293,0.0132986457962,0.013729458857,-0.0300331025108,0.0097098478981,0.0607366596922,-0.0128692023155,-0.0221373498858
1,0.0191767846723,0.0500080797498,0.0413445093224,-0.0118638458107,-0.0118797818094,-0.0135939386481,-0.00732870527613,-0.0266736509577,0.00987573185266,0.0128089335267
2,-0.0203332000076,0.0266382125763,-0.0389990672811,-0.017777519562,0.0127868199621,-0.00596770228189,-0.0223268641586,-0.023152572415,-0.00700707699816,-0.0380340562197
3,0.0014206991819,-0.0538128052695,-0.0133465260905,-0.00262984903161,-0.0636934739858,-0.0411177352489,0.0225883214158,-0.0123480666579,0.0187356648257,-0.058372513909
4,0.00864771267018,0.114835959861,0.00361711846446,0.00789413132211,0.0412976559192,0.0198546937033,0.0208700535229,0.0640898336902,-0.0111530482356,0.0243334775592
Here’s the stock data — attached as a CSV. Columns are stock tickers, rows are periodic return observations (already as decimal returns, not prices).
Reading the attachment. One leading unnamed column that is just a row index, then ten columns — tickers AA through JJ — each holding 244 periodic return observations. The user says these are already decimal returns, not prices, so no conversion is needed before computing means and covariances. Dropping the index column and adding a Stock entity to the Input section with these fields.
Got it — ten stocks (AA through JJ), 244 return observations each, already expressed as decimal returns. Nothing needs rescaling; the leading index column is dropped on load. I have added a Stock entity to the specification’s Input section.
That was the last open item. Shall I proceed to Step 2: Model Implementation using Python with gurobipy?
Where your files live
Everything in the conversation — the file you attached and every artifact Modeler generates from it — lands in the conversation workspace as an attachment. Each one opens in a side panel next to the chat, so you can read the specification while you keep answering questions, or scroll the code while you ask for a change. From that panel you can copy the contents or download the file.
By the end of this conversation the workspace holds six items:
Attachment |
What it is |
|---|---|
|
The stock return data you attached. |
|
The specification, rewritten after every answer. |
|
The |
|
The |
|
Output of the test runs, including the solver logs. |
|
The mathematical formulation, generated on request. |
Starting from files you already have
The same upload path runs in reverse. Attach the files of an existing
optimization project — a specification, a model script, a test file, or any
subset — and ask Modeler to check the project. It completes the
specification where it is thin (asking clarifying questions, as above), fills
in a missing implementation or test suite, reconciles code that has drifted
from the specification, and runs the tests. Point it at a model written for
another solver or another language and it recovers a specification from that
file, asks you to confirm it, then reimplements the model in gurobipy.
The specification is the artifact the questions are building toward, and the one everything else is generated from. Modeler rewrites it after every answer — even while it is still incomplete — so it doubles as the running record of what has been agreed and what is still open. It follows a fixed seven-section structure and is written entirely in business terms: no mathematics, so the person who owns the problem can read it and say whether it is right.
1. Overview
An investor wants to allocate a fixed budget across ten stocks in a way that minimizes overall portfolio risk, measured as portfolio variance, using the classical Markowitz mean-variance framework. The model can also sweep across a range of target expected returns to trace the efficient frontier, so the investor can compare risk against return across several scenarios rather than seeing only the single minimum-risk point.
2. Objective
Minimize portfolio variance (total risk), given the historical covariance of stock returns. For the efficient frontier sweep, this same objective is solved repeatedly, each time with the portfolio’s expected return pinned to a different target level.
3. Constraints
C1 — Full investment. Portfolio weights must sum to exactly 1, so the entire budget is allocated across the ten stocks — none of it sits in cash.
C2 — Long-only. No stock’s weight may be negative. Short-selling is not available to this investor, so every position is a plain purchase.
C3 — Target return (efficient frontier only). When tracing the frontier, an added constraint pins the portfolio’s expected return to a specific target level. This constraint is applied one target at a time and removed once the sweep is complete — it plays no part in the single minimum-variance solve.
4. Input
Entity |
Fields |
Values |
|---|---|---|
Stock |
ticker, periodic return observation, per period |
10 stocks (AA–JJ), 244 periods, supplied in |
BB has the highest historical average return of the ten stocks (about 1.42% per period) but is also the most volatile — which is exactly why the unconstrained minimum-variance portfolio below allocates it nothing at all: diversification, not high expected return, is what the objective actually rewards.
5. Output
The weight of each stock in the minimum-variance portfolio.
The resulting portfolio variance, volatility and expected return.
The efficient frontier: a table of target return, achieved volatility and variance across 20 evenly spaced points, plus whether each point is achievable.
6. Test scenarios
ID |
Scenario |
Acceptance criteria |
|---|---|---|
T1 |
Baseline — minimum-variance solve, 4-stock synthetic dataset. |
Optimal; weights sum to 1; all weights non-negative; variance is positive. |
T2 |
Efficient frontier — automatic 20-point sweep, same dataset. |
All 20 points optimal; volatility non-decreasing along the frontier; first point matches T1’s variance. |
T3 |
Efficient frontier — a user-supplied list of 3 target returns instead of the automatic sweep. |
Exactly 3 points returned, each matching its requested target return. |
T4 |
A target return set to 100x the highest achievable single-stock return. |
Reported as infeasible — no combination of weights can reach it. |
T5 |
A single-stock universe (only one stock available). |
Optimal; that stock receives 100% of the weight; variance equals its own sample variance. |
T6 |
Two perfectly negatively correlated stocks (diversification benefit). |
Optimal; both stocks receive non-zero weight; portfolio variance is strictly below either stock’s individual variance. |
7. Out of scope
Short selling (negative weights).
Per-stock weight caps or floors, cardinality limits, and sector or group allocation constraints.
Transaction costs, rebalancing over time, or a multi-period horizon.
Chart or plot generation — the model returns frontier data for the caller to visualize however it likes.
With the specification settled, Modeler writes the gurobipy implementation.
This is the version that came out of the run-and-fix loop in the Cloud solve
tab, after Modeler ran the tests and fixed what it found.
"""
Portfolio Optimization Model -- Markowitz Minimum-Variance
============================================================
Minimizes portfolio variance (risk) given historical return observations.
Supports:
- Single minimum-variance solve (no target return)
- Efficient frontier sweep: automatic (N evenly spaced points) or custom list
Constraints: fully invested (weights sum to 1), long-only (no shorting).
"""
import math
import numpy as np
import pandas as pd
import gurobipy as gp
from gurobipy import GRB
class PortfolioOptimizer:
"""
Markowitz minimum-variance portfolio optimizer.
Parameters
----------
env : gp.Env
A started Gurobi environment.
"""
def __init__(self, env: gp.Env):
"""Initialize optimizer with a Gurobi environment."""
self.env = env
self.model: gp.Model | None = None
# Data
self.stocks: list[str] = []
self.expected_returns: np.ndarray | None = None # shape (n_stocks,)
self.cov_matrix: np.ndarray | None = None # shape (n_stocks, n_stocks)
# Model components
self._weights: dict | None = None # gurobi vars keyed by ticker
# ------------------------------------------------------------------
# Context manager
# ------------------------------------------------------------------
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self.model is not None:
self.model.dispose()
return False
# ------------------------------------------------------------------
# Data loading
# ------------------------------------------------------------------
def load_returns_csv(self, filepath: str) -> None:
"""
Load return observations from a CSV file.
Columns = stock tickers, rows = periodic return observations (decimals).
An optional leading row-index column (unnamed first column) is dropped
automatically via index_col=0.
"""
df = pd.read_csv(filepath, index_col=0)
self._process_returns_dataframe(df)
def load_returns_dataframe(self, df: pd.DataFrame) -> None:
"""
Load return observations from a pandas DataFrame.
Columns = stock tickers, rows = periodic return observations (decimals).
"""
self._process_returns_dataframe(df)
def _process_returns_dataframe(self, df: pd.DataFrame) -> None:
"""Compute expected returns and covariance matrix from a returns DataFrame."""
self.stocks = list(df.columns)
returns_array = df.values.astype(float) # shape (T, n_stocks)
self.expected_returns = returns_array.mean(axis=0)
self.cov_matrix = np.cov(returns_array, rowvar=False, ddof=1)
# Ensure 2-D even for single stock
if self.cov_matrix.ndim == 0:
self.cov_matrix = np.array([[float(self.cov_matrix)]])
# ------------------------------------------------------------------
# Model building
# ------------------------------------------------------------------
def build_model(self) -> None:
"""Build the base minimum-variance quadratic model (no target return constraint)."""
if self.stocks is None or self.cov_matrix is None:
raise RuntimeError("Load return data before building the model.")
self.model = gp.Model("PortfolioOptimization", env=self.env)
self.model.setParam("OutputFlag", 0)
self._create_variables()
self._add_base_constraints()
self._set_objective()
def _create_variables(self) -> None:
"""Declare portfolio weight variables -- one per stock, long-only (lb=0)."""
# Long-only: default lb=0, ub=1 (weight cannot exceed 100% of portfolio)
self._weights = {
ticker: self.model.addVar(lb=0.0, ub=1.0, name=f"w_{ticker}")
for ticker in self.stocks
}
def _add_base_constraints(self) -> None:
"""Add the fully-invested constraint (weights sum to 1)."""
self.model.addConstr(
gp.quicksum(self._weights[s] for s in self.stocks) == 1.0,
name="fully_invested"
)
def _set_objective(self) -> None:
"""Set the objective: minimize portfolio variance (w^T Sigma w)."""
n = len(self.stocks)
w = [self._weights[s] for s in self.stocks]
portfolio_variance = gp.quicksum(
self.cov_matrix[i, j] * w[i] * w[j]
for i in range(n)
for j in range(n)
)
self.model.setObjective(portfolio_variance, GRB.MINIMIZE)
# ------------------------------------------------------------------
# Solving
# ------------------------------------------------------------------
def solve(self) -> dict:
"""
Solve for the unconstrained minimum-variance portfolio.
Returns
-------
dict with keys: status, is_optimal, objective_value, weights,
expected_return, volatility
"""
if self.model is None:
raise RuntimeError("Call build_model() before solve().")
self.model.optimize()
return self._extract_solution()
def compute_efficient_frontier(
self,
num_points: int = 20,
target_returns: list[float] | None = None,
) -> list[dict]:
"""
Compute the efficient frontier by solving the minimum-variance model
parametrically over a range of target expected returns.
Parameters
----------
num_points : int
Number of evenly spaced target return levels (used only when
target_returns is None). Default is 20.
target_returns : list of float, optional
Custom list of target return values. If provided, num_points is
ignored.
Returns
-------
List of solution dicts (one per target return), each with an extra
key 'target_return' indicating the requested return level.
"""
if self.model is None:
raise RuntimeError("Call build_model() before compute_efficient_frontier().")
# --- Determine target return range ---
min_var_sol = self.solve()
min_var_return = min_var_sol.get("expected_return", 0.0)
max_single_return = float(np.max(self.expected_returns))
if target_returns is None:
targets = np.linspace(min_var_return, max_single_return, num_points).tolist()
else:
targets = list(target_returns)
# --- Add a target return constraint (will be updated each iteration) ---
portfolio_return_expr = gp.quicksum(
self.expected_returns[i] * self._weights[self.stocks[i]]
for i in range(len(self.stocks))
)
target_constr = self.model.addConstr(
portfolio_return_expr == min_var_return,
name="target_return"
)
frontier = []
for target in targets:
# Update the RHS of the target return constraint
target_constr.RHS = target
self.model.optimize()
sol = self._extract_solution()
sol["target_return"] = target
frontier.append(sol)
# Remove the target return constraint to restore the base model
self.model.remove(target_constr)
self.model.update()
return frontier
# ------------------------------------------------------------------
# Solution extraction
# ------------------------------------------------------------------
def _extract_solution(self) -> dict:
"""Extract solution values from the solved model."""
status = self.model.Status
is_optimal = status == GRB.OPTIMAL
if not is_optimal:
return {
"status": status,
"is_optimal": False,
"objective_value": None,
"weights": {},
"expected_return": None,
"volatility": None,
}
variance = self.model.ObjVal
volatility = math.sqrt(max(variance, 0.0))
weights = {
s: self._weights[s].X
for s in self.stocks
if self._weights[s].X > 1e-8
}
expected_return = float(
sum(
self.expected_returns[i] * self._weights[self.stocks[i]].X
for i in range(len(self.stocks))
)
)
return {
"status": status,
"is_optimal": True,
"objective_value": variance,
"weights": weights,
"expected_return": expected_return,
"volatility": volatility,
}
# ------------------------------------------------------------------
# Display
# ------------------------------------------------------------------
def display_min_variance_solution(self, solution: dict) -> None:
"""Print a formatted summary of the minimum-variance portfolio."""
print("\n" + "=" * 55)
print(" MINIMUM-VARIANCE PORTFOLIO")
print("=" * 55)
if not solution["is_optimal"]:
print(f" No optimal solution found. Status: {solution['status']}")
return
print(f" Portfolio Variance : {solution['objective_value']:.8f}")
print(f" Portfolio Volatility: {solution['volatility']:.6f}")
print(f" Expected Return : {solution['expected_return']:.6f}")
print("\n Stock Weights:")
for ticker, w in sorted(solution["weights"].items(), key=lambda x: -x[1]):
print(f" {ticker:<15s} {w:.4%}")
print("=" * 55)
def display_efficient_frontier(self, frontier: list[dict]) -> None:
"""Print a formatted summary of the efficient frontier."""
print("\n" + "=" * 65)
print(" EFFICIENT FRONTIER")
print("=" * 65)
print(f" {'Point':>5} {'Target Return':>14} {'Volatility':>12} {'Variance':>12} {'Optimal':>8}")
print(" " + "-" * 59)
for idx, sol in enumerate(frontier):
target = sol.get("target_return", float("nan"))
if sol["is_optimal"]:
print(
f" {idx+1:>5} {target:>14.6f} {sol['volatility']:>12.6f}"
f" {sol['objective_value']:>12.8f} {'Yes':>8}"
)
else:
print(f" {idx+1:>5} {target:>14.6f} {'N/A':>12} {'N/A':>12} {'No':>8}")
print("=" * 65)
# ----------------------------------------------------------------------
# Main script -- run against portfolio.csv (10 stocks, 244 observations)
# ----------------------------------------------------------------------
if __name__ == "__main__":
import os
CSV_PATH = "portfolio.csv"
with gp.Env(empty=True) as env:
env.setParam("OutputFlag", 0)
env.setParam("TimeLimit", 60)
env.start()
with PortfolioOptimizer(env) as optimizer:
if os.path.exists(CSV_PATH) and os.path.getsize(CSV_PATH) > 0:
print(f"Loading data from {CSV_PATH} ...")
optimizer.load_returns_csv(CSV_PATH)
else:
raise FileNotFoundError(f"{CSV_PATH} not found or empty.")
print(f"Stocks loaded : {optimizer.stocks}")
print(f"Observations : {optimizer.cov_matrix.shape[0]} periods")
# Build the base model
optimizer.build_model()
# --- Minimum-variance portfolio ---
min_var_sol = optimizer.solve()
optimizer.display_min_variance_solution(min_var_sol)
# --- Efficient frontier (automatic sweep, 20 points) ---
frontier = optimizer.compute_efficient_frontier(num_points=20)
optimizer.display_efficient_frontier(frontier)
Alongside the implementation, Modeler writes a pytest suite with one test
class per scenario in the specification. Each test references its scenario
identifier, and uses small hard-coded return data and a short solver time
limit so the suite runs in well under a second.
"""
pytest suite for PortfolioOptimizer
====================================
Covers all test scenarios defined in portfolio_modeler_spec.rst.
All datasets are small (4 stocks x 10 periods max) to minimise Gurobi
resource usage. Each test is capped at 10 s solver time.
"""
import math
import time
import numpy as np
import pandas as pd
import pytest
import gurobipy as gp
from portfolio_modeler import PortfolioOptimizer
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def gurobi_env():
"""Create and start a single Gurobi environment for the entire test module."""
with gp.Env(empty=True) as env:
env.setParam("OutputFlag", 0)
env.setParam("TimeLimit", 10)
env.start()
yield env
@pytest.fixture
def four_stock_df():
"""T1/T2/T3/T4 -- 4 stocks, 10 return observations (synthetic)."""
return pd.DataFrame({
"STOCK_A": [ 0.010, 0.005, -0.003, 0.012, 0.008,
-0.002, 0.015, 0.003, 0.007, -0.005],
"STOCK_B": [ 0.020, -0.010, 0.015, 0.005, -0.005,
0.010, 0.008, -0.003, 0.012, 0.001],
"STOCK_C": [-0.005, 0.020, 0.010, -0.008, 0.015,
0.005, -0.012, 0.018, 0.002, 0.008],
"STOCK_D": [ 0.015, 0.008, -0.002, 0.020, 0.003,
0.012, 0.007, -0.005, 0.010, 0.006],
})
@pytest.fixture
def single_stock_df():
"""T5 -- single stock, 3 return observations."""
return pd.DataFrame({
"STOCK_A": [0.010, 0.005, -0.003],
})
@pytest.fixture
def two_stock_neg_corr_df():
"""T6 -- two negatively-correlated stocks, 5 return observations."""
return pd.DataFrame({
"STOCK_A": [ 0.05, -0.05, 0.03, -0.03, 0.01],
"STOCK_B": [-0.05, 0.05, -0.03, 0.03, -0.01],
})
# ---------------------------------------------------------------------------
# T1: Minimum Variance Portfolio -- Basic
# ---------------------------------------------------------------------------
class TestT1MinimumVarianceBasic:
"""T1: Minimum-variance solve on the 4-stock synthetic dataset."""
def test_is_optimal(self, gurobi_env, four_stock_df):
"""T1 -- solution must be optimal."""
start = time.time()
with PortfolioOptimizer(gurobi_env) as opt:
opt.load_returns_dataframe(four_stock_df)
opt.build_model()
sol = opt.solve()
assert time.time() - start < 10, "Test exceeded 10 s"
assert sol["is_optimal"] is True
def test_weights_sum_to_one(self, gurobi_env, four_stock_df):
"""T1 -- fully-invested constraint: weights must sum to 1."""
with PortfolioOptimizer(gurobi_env) as opt:
opt.load_returns_dataframe(four_stock_df)
opt.build_model()
opt.solve()
total = sum(opt._weights[s].X for s in opt.stocks)
assert abs(total - 1.0) < 1e-6, f"Weights sum to {total}, expected 1.0"
def test_weights_non_negative(self, gurobi_env, four_stock_df):
"""T1 -- long-only constraint: no negative weights."""
with PortfolioOptimizer(gurobi_env) as opt:
opt.load_returns_dataframe(four_stock_df)
opt.build_model()
opt.solve()
all_weights = {s: opt._weights[s].X for s in opt.stocks}
for ticker, w in all_weights.items():
assert w >= -1e-6, f"Stock {ticker} has negative weight {w}"
def test_objective_positive(self, gurobi_env, four_stock_df):
"""T1 -- portfolio variance must be positive."""
with PortfolioOptimizer(gurobi_env) as opt:
opt.load_returns_dataframe(four_stock_df)
opt.build_model()
sol = opt.solve()
assert sol["objective_value"] > 0
def test_volatility_equals_sqrt_variance(self, gurobi_env, four_stock_df):
"""T1 -- volatility must equal sqrt(variance)."""
with PortfolioOptimizer(gurobi_env) as opt:
opt.load_returns_dataframe(four_stock_df)
opt.build_model()
sol = opt.solve()
expected_vol = math.sqrt(sol["objective_value"])
assert abs(sol["volatility"] - expected_vol) < 1e-6
def test_expected_return_is_float(self, gurobi_env, four_stock_df):
"""T1 -- expected_return must be a finite float."""
with PortfolioOptimizer(gurobi_env) as opt:
opt.load_returns_dataframe(four_stock_df)
opt.build_model()
sol = opt.solve()
assert isinstance(sol["expected_return"], float)
assert math.isfinite(sol["expected_return"])
# ---------------------------------------------------------------------------
# T2: Efficient Frontier -- Automatic Sweep
# ---------------------------------------------------------------------------
class TestT2EfficientFrontierAutomatic:
"""T2: Automatic 20-point efficient frontier on 4-stock dataset."""
@pytest.fixture
def frontier(self, gurobi_env, four_stock_df):
with PortfolioOptimizer(gurobi_env) as opt:
opt.load_returns_dataframe(four_stock_df)
opt.build_model()
f = opt.compute_efficient_frontier(num_points=20)
return f
def test_frontier_has_20_points(self, frontier):
"""T2 -- frontier must contain exactly 20 points."""
assert len(frontier) == 20
def test_all_points_optimal(self, frontier):
"""T2 -- all frontier points must be optimal."""
for i, sol in enumerate(frontier):
assert sol["is_optimal"] is True, f"Point {i} not optimal"
def test_volatility_non_decreasing(self, frontier):
"""T2 -- volatility must be non-decreasing along the frontier."""
vols = [sol["volatility"] for sol in frontier]
for i in range(1, len(vols)):
assert vols[i] >= vols[i - 1] - 1e-6, (
f"Volatility decreased from point {i-1} ({vols[i-1]:.8f}) "
f"to point {i} ({vols[i]:.8f})"
)
def test_first_point_close_to_min_variance(self, gurobi_env, four_stock_df, frontier):
"""T2 -- first frontier point variance should equal the unconstrained min-variance."""
with PortfolioOptimizer(gurobi_env) as opt:
opt.load_returns_dataframe(four_stock_df)
opt.build_model()
min_var_sol = opt.solve()
assert abs(frontier[0]["objective_value"] - min_var_sol["objective_value"]) < 1e-5
def test_frontier_weights_sum_to_one(self, gurobi_env, four_stock_df):
"""T2 -- weights must sum to 1 for every frontier point."""
with PortfolioOptimizer(gurobi_env) as opt:
opt.load_returns_dataframe(four_stock_df)
opt.build_model()
frontier = opt.compute_efficient_frontier(num_points=20)
for i, sol in enumerate(frontier):
total = sum(sol["weights"].values())
assert abs(total - 1.0) < 1e-3, f"Point {i}: weights sum to {total}"
def test_frontier_weights_non_negative(self, gurobi_env, four_stock_df):
"""T2 -- all weights must be non-negative for every frontier point."""
with PortfolioOptimizer(gurobi_env) as opt:
opt.load_returns_dataframe(four_stock_df)
opt.build_model()
frontier = opt.compute_efficient_frontier(num_points=20)
for i, sol in enumerate(frontier):
for ticker, w in sol["weights"].items():
assert w >= -1e-6, f"Point {i}, {ticker}: w={w}"
# ---------------------------------------------------------------------------
# T3: Efficient Frontier -- Custom Target Returns
# ---------------------------------------------------------------------------
class TestT3CustomTargetReturns:
"""T3: User-supplied list of 3 target returns."""
def test_custom_three_points(self, gurobi_env, four_stock_df):
"""T3 -- frontier with custom 3 targets has exactly 3 points, all optimal."""
arr = four_stock_df.values.astype(float)
means = arr.mean(axis=0)
min_ret = float(means.min())
max_ret = float(means.max())
mid_ret = (min_ret + max_ret) / 2.0
custom_targets = [min_ret, mid_ret, max_ret]
with PortfolioOptimizer(gurobi_env) as opt:
opt.load_returns_dataframe(four_stock_df)
opt.build_model()
frontier = opt.compute_efficient_frontier(target_returns=custom_targets)
assert len(frontier) == 3
for i, sol in enumerate(frontier):
assert sol["is_optimal"] is True, f"Point {i} not optimal"
def test_custom_target_returns_stored(self, gurobi_env, four_stock_df):
"""T3 -- target_return key in each solution matches the supplied value."""
arr = four_stock_df.values.astype(float)
means = arr.mean(axis=0)
custom_targets = [float(means.min()), float(means.max())]
with PortfolioOptimizer(gurobi_env) as opt:
opt.load_returns_dataframe(four_stock_df)
opt.build_model()
frontier = opt.compute_efficient_frontier(target_returns=custom_targets)
for sol, target in zip(frontier, custom_targets):
assert abs(sol["target_return"] - target) < 1e-12
# ---------------------------------------------------------------------------
# T4: Infeasible Target Return
# ---------------------------------------------------------------------------
class TestT4InfeasibleTarget:
"""T4: Target return far above achievable maximum -- must be infeasible."""
def test_infeasible_target_not_optimal(self, gurobi_env, four_stock_df):
"""T4 -- wildly high target return yields is_optimal=False."""
arr = four_stock_df.values.astype(float)
max_ret = float(arr.mean(axis=0).max())
infeasible_target = [max_ret * 100] # clearly unachievable
with PortfolioOptimizer(gurobi_env) as opt:
opt.load_returns_dataframe(four_stock_df)
opt.build_model()
frontier = opt.compute_efficient_frontier(target_returns=infeasible_target)
sol = frontier[0]
assert sol["is_optimal"] is False
assert sol["objective_value"] is None
assert sol["weights"] == {}
# ---------------------------------------------------------------------------
# T5: Single Stock Edge Case
# ---------------------------------------------------------------------------
class TestT5SingleStock:
"""T5: Only one stock -- trivially must be weighted at 100%."""
def test_single_stock_optimal(self, gurobi_env, single_stock_df):
"""T5 -- single stock portfolio is optimal."""
with PortfolioOptimizer(gurobi_env) as opt:
opt.load_returns_dataframe(single_stock_df)
opt.build_model()
sol = opt.solve()
assert sol["is_optimal"] is True
def test_single_stock_weight_is_one(self, gurobi_env, single_stock_df):
"""T5 -- single stock receives weight = 1.0."""
with PortfolioOptimizer(gurobi_env) as opt:
opt.load_returns_dataframe(single_stock_df)
opt.build_model()
opt.solve()
w = opt._weights["STOCK_A"].X
assert abs(w - 1.0) < 1e-6
def test_single_stock_variance_matches(self, gurobi_env, single_stock_df):
"""T5 -- portfolio variance equals the sample variance of STOCK_A returns."""
with PortfolioOptimizer(gurobi_env) as opt:
opt.load_returns_dataframe(single_stock_df)
opt.build_model()
sol = opt.solve()
sample_var = float(np.var(single_stock_df["STOCK_A"].values, ddof=1))
assert abs(sol["objective_value"] - sample_var) < 1e-10
# ---------------------------------------------------------------------------
# T6: Two Stocks -- Diversification Benefit
# ---------------------------------------------------------------------------
class TestT6DiversificationBenefit:
"""T6: Two negatively correlated stocks -- optimizer should diversify."""
def test_diversification_optimal(self, gurobi_env, two_stock_neg_corr_df):
"""T6 -- solution is optimal."""
with PortfolioOptimizer(gurobi_env) as opt:
opt.load_returns_dataframe(two_stock_neg_corr_df)
opt.build_model()
sol = opt.solve()
assert sol["is_optimal"] is True
def test_both_stocks_nonzero(self, gurobi_env, two_stock_neg_corr_df):
"""T6 -- both stocks receive non-zero weight (diversification)."""
with PortfolioOptimizer(gurobi_env) as opt:
opt.load_returns_dataframe(two_stock_neg_corr_df)
opt.build_model()
opt.solve()
wa = opt._weights["STOCK_A"].X
wb = opt._weights["STOCK_B"].X
assert wa > 1e-6, "STOCK_A has zero weight"
assert wb > 1e-6, "STOCK_B has zero weight"
def test_portfolio_variance_lower_than_individuals(self, gurobi_env, two_stock_neg_corr_df):
"""T6 -- portfolio variance is less than variance of each individual stock."""
arr = two_stock_neg_corr_df.values.astype(float)
var_a = float(np.var(arr[:, 0], ddof=1))
var_b = float(np.var(arr[:, 1], ddof=1))
with PortfolioOptimizer(gurobi_env) as opt:
opt.load_returns_dataframe(two_stock_neg_corr_df)
opt.build_model()
sol = opt.solve()
assert sol["objective_value"] < var_a, (
f"Portfolio variance {sol['objective_value']} >= STOCK_A variance {var_a}"
)
assert sol["objective_value"] < var_b, (
f"Portfolio variance {sol['objective_value']} >= STOCK_B variance {var_b}"
)
def test_weights_sum_to_one(self, gurobi_env, two_stock_neg_corr_df):
"""T6 -- weights must sum to 1."""
with PortfolioOptimizer(gurobi_env) as opt:
opt.load_returns_dataframe(two_stock_neg_corr_df)
opt.build_model()
opt.solve()
total = sum(opt._weights[s].X for s in opt.stocks)
assert abs(total - 1.0) < 1e-6
Generating code and tests is the easy half. Modeler also runs the suite in an isolated environment created fresh for this conversation, solving against Gurobi in the cloud — you do not need Gurobi installed locally, and you do not need a license of your own to see the model solve. It reads the output and fixes what it finds, so what you get back is a model that has been executed, not one that merely looks plausible.
The specification is now complete. One last question before moving to
implementation: should the ten-stock universe and the frontier sweep be
exposed as a reusable PortfolioOptimizer class with separate
solve() and compute_efficient_frontier() methods, or as a single
function that returns both results in one call?
The class works for me. Please finalize the complete specification, implement the model in gurobipy, write a pytest suite covering all the test scenarios, run the tests against Gurobi, fix anything that fails, and report back the full results including the minimum-risk portfolio and the efficient frontier.
Building PortfolioOptimizer with the class shape just agreed:
load_returns_csv / load_returns_dataframe for data, build_model
for C1 and C2, solve for the single minimum-variance point, and
compute_efficient_frontier for the sweep — added as a parametric
target-return constraint (C3) that gets updated per point and removed
afterward, rather than rebuilding the model 20 times.
The run, and the loop
Everything is working perfectly. Here is the full report.
$ pytest test_portfolio_modeler.py -q
......................
22 passed in 0.33s
Gurobi Optimizer version 12.0.3 build v12.0.3rc0
Optimize a model with 1 rows, 10 columns and 10 nonzeros
Model has 55 quadratic objective terms
Presolved: 1 rows, 10 columns, 10 nonzeros
Barrier solved model in 10 iterations and 0.00 seconds (0.00 work units)
Optimal objective 1.65877098e-04
All 22 tests passed on the first run — no fix needed here. A full summary of everything that was built:
Results — Minimum-Variance Portfolio
Stock |
Weight |
|---|---|
AA |
46.75% |
HH |
20.52% |
II |
14.89% |
DD |
12.53% |
FF |
3.25% |
GG |
1.72% |
CC |
0.34% |
BB, EE, JJ |
0.00% |
Metric |
Value |
|---|---|
Portfolio variance |
0.00016588 |
Portfolio volatility |
1.288% per period |
Expected return |
0.244% per period |
AA anchors the portfolio at 47%, complemented by HH, II and DD for diversification. BB, EE and JJ receive zero allocation — the covariance structure says they add more risk than diversification benefit, despite BB having the single highest average return of the ten.
Efficient frontier (20-point automatic sweep)
All 20 points solve to optimality. Volatility rises monotonically from 1.288% at the minimum-variance point to 5.446% at the maximum achievable return (1.422% per period, the return of the best single stock) — 4.2x more risk for that highest-return point.
Files created
File |
Description |
|---|---|
|
Complete problem specification |
|
Input return dataset |
|
Gurobipy implementation ( |
|
Pytest suite covering scenarios T1–T6 |
Test results
Scenario |
Description |
Result |
|---|---|---|
T1 |
Minimum variance — basic |
PASSED |
T2 |
Efficient frontier — auto sweep |
PASSED |
T3 |
Efficient frontier — custom targets |
PASSED |
T4 |
Infeasible target return |
PASSED |
T5 |
Single stock edge case |
PASSED |
T6 |
Two stocks — diversification benefit |
PASSED |
Let me know if you would like any changes, such as adding weight caps, or generating the mathematical formulation.
That log lands in the workspace as execution.log alongside the code, so the
solve is auditable rather than asserted.
The loop ends one of two ways. Either every test passes, as here — or Modeler concludes a failure reflects a genuine contradiction between the specification, the implementation and the tests. In that case it stops instead of guessing, tells you what it found, and asks which side is the one to correct. That second path is what the Changing the model tab demonstrates.
What the cloud environment gives you, and what it does not
The environment is built for validating a model, not for running production
workloads. Python 3 is available with gurobipy, gurobipy-pandas,
numpy, pandas, scipy and pytest pre-installed. Within that:
Solves use a Gurobi trial license, capped at 2,000 variables and 2,000 constraints. This model, at 10 columns and 1 row, is nowhere near the cap — but a full-scale version of it, with hundreds of stocks and per-sector constraints, could be.
Execution is limited to 60 seconds per command, with tight CPU and memory limits. This is why the generated tests use small hard-coded data and a short solver time limit.
Additional packages cannot be installed, there is no internet access, and Compute Server and Instant Cloud cannot be used.
So treat a passing suite as evidence that the model is right, not as a performance measurement. Download the implementation and run it against your own license and your own full data set for that. See Solving limits for the details.
Business problems do not hold still. The specification above puts per-stock weight caps or floors out of scope — a reasonable simplification, until the investor looks at the baseline plan and notices it puts 47% of the money into a single stock, AA. Ask for the change in plain language and Modeler updates the specification first, then propagates it to the implementation and the tests, in that order.
AA is almost half the portfolio (46.75%) — that feels too concentrated for comfort. Can you add a rule that no single stock can be more than 30% of the portfolio?
This is a scope change: a maximum-weight cap is currently listed under “Out of scope.” It is also the simplest kind of change to implement — a per-variable upper bound, not a new constraint row — so Gurobi’s presolve can use it directly. Adding C4.
Done. I have added C4 to the specification: no stock may exceed 30% of the portfolio, implemented as a per-stock weight upper bound. Re-running the minimum-variance solve with the cap in place: AA is now capped at exactly 30%, and the freed-up 16.75 points redistribute mostly to HH and II. The cost is modest — variance rises from 0.00016588 to 0.00017301, about 4.3%. Let me know if you’d like anything else adjusted.
I also want to make sure my two highest-expected-return stocks aren’t ignored — can you require at least 20% weight in my two highest-expected-return stocks?
“At least 20% in my two highest-expected-return stocks” is genuinely ambiguous, and the two readings do not just differ by a little: a combined floor (the two together sum to at least 20%) can be satisfied by concentrating entirely in one of them, while an individual floor (each one at least 20%) forces both to appear meaningfully. The top-2 stocks by expected return are BB and DD, and BB is also the most volatile stock in the whole dataset — so which reading gets implemented determines whether BB enters the portfolio at all. I should not silently pick one; I’ll implement the more literal combined reading for now but flag the alternative immediately, before the user assumes variety across both stocks is guaranteed.
I’ve added a floor, but there are two different ways to read “at least 20% in my two highest-expected-return stocks,” and they produce very different portfolios:
w_BB + w_DD >= 0.20. The optimizer can satisfy this with either stock alone.w_BB >= 0.20 and w_DD >= 0.20 separately. Both are guaranteed a meaningful position.I implemented the combined reading, which is why BB receives 0% and DD alone satisfies the floor at exactly 20%. Which did you mean?
Let’s go with option 2 — the individual floor: at least 20% in BB and at least 20% in DD separately.
Specification, then code, then tests
The specification changes first. Section 3 gains a new constraint, and the out-of-scope bullet narrows to exclude only floors on stocks other than the top two:
C5 -- Minimum individual weight for each of the top-2 return stocks. Each
of the two stocks with the highest historical expected returns must
individually receive at least a specified minimum weight (default 20%).
The constraint cannot be satisfied by concentrating the floor in only one
of the two stocks.
The implementation follows the specification, not the other way around. Both C4 and C5 are encoded as variable bounds set at creation time, not as separate constraint rows:
class PortfolioOptimizer:
def __init__(
self,
env: gp.Env,
+ max_weight: float = 1.0,
+ min_top2_individual_weight: float = 0.0,
):
+ if not (0.0 < max_weight <= 1.0):
+ raise ValueError("max_weight must be in the range (0, 1].")
+ if not (0.0 <= min_top2_individual_weight <= 1.0):
+ raise ValueError("min_top2_individual_weight must be in the range [0.0, 1.0].")
self.env = env
+ self.max_weight = max_weight
+ self.min_top2_individual_weight = min_top2_individual_weight
self.model = None
+ self._top2_stocks = []
def build_model(self):
self.model = gp.Model("PortfolioOptimization", env=self.env)
self.model.setParam("OutputFlag", 0)
+ self._identify_top2_stocks()
self._create_variables()
self._add_base_constraints()
self._set_objective()
+ def _identify_top2_stocks(self):
+ sorted_indices = np.argsort(self.expected_returns)[::-1]
+ self._top2_stocks = [self.stocks[i] for i in sorted_indices[:2]]
def _create_variables(self):
- self._weights = {
- ticker: self.model.addVar(lb=0.0, ub=1.0, name=f"w_{ticker}")
- for ticker in self.stocks
- }
+ self._weights = {}
+ for ticker in self.stocks:
+ lb = (
+ self.min_top2_individual_weight
+ if ticker in self._top2_stocks
+ else 0.0
+ )
+ self._weights[ticker] = self.model.addVar(
+ lb=lb, ub=self.max_weight, name=f"w_{ticker}"
+ )
A new test class checks that each top-2 stock individually clears the floor, that both floors together force a combined minimum of 40%, and that a floor exceeding the cap is correctly reported infeasible:
class TestT8MinTop2IndividualWeight:
"""T8: Individual floor on each of the two highest-return stocks (C5)."""
def test_top2_stock_1_meets_individual_floor(self, gurobi_env, four_stock_df):
"""T8: the highest-return stock individually holds >= 20%."""
floor = 0.20
top2 = self._get_top2_stocks(four_stock_df)
with PortfolioOptimizer(gurobi_env, min_top2_individual_weight=floor) as opt:
opt.load_returns_dataframe(four_stock_df)
opt.build_model()
opt.solve()
w1 = opt._weights[top2[0]].X
assert w1 >= floor - 1e-6
def test_infeasible_when_floor_exceeds_max_weight(self, gurobi_env, four_stock_df):
"""T8: individual floor > max_weight is infeasible (lb > ub)."""
with PortfolioOptimizer(
gurobi_env, max_weight=0.30, min_top2_individual_weight=0.35
) as opt:
opt.load_returns_dataframe(four_stock_df)
opt.build_model()
sol = opt.solve()
assert sol["is_optimal"] is False
Modeler re-runs the whole suite, not just the new tests — all 40 pass, 6 of them new for C5.
Where it stops and asks
The ambiguity flagged during the conversation turns out to matter a great deal on the real data, not just in theory. BB carries the highest average return of all ten stocks, but it is also the single most volatile — so the two readings of “at least 20% in my top-2 return stocks” land on very different portfolios:
Reading |
BB weight |
Portfolio variance |
|---|---|---|
Combined total >= 20% |
0.00% (DD alone satisfies it) |
0.00017516 |
At least 20% individually |
20.00% (forced) |
0.00033216 |
I implemented the combined-total reading first, and it is a good thing I asked rather than assumed variety was implied: the two readings do not just differ in cost, they differ by nearly 2x in risk. The combined reading satisfies the floor entirely through DD, avoiding BB’s volatility altogether. Forcing BB in individually nearly doubles portfolio variance, from 0.00017516 to 0.00033216. Which did you actually mean?
This is the loop ending in its second way: not a bug Modeler can fix, but a genuine ambiguity in what “my two highest-return stocks” was supposed to guarantee — one where picking wrong would have meant confidently returning a portfolio that quietly avoids the very stock the investor asked to include. It reports what it found and hands the decision back rather than guessing.
The answer flips
Constraints active |
AA |
BB |
Variance |
|---|---|---|---|
No cap, no floor (original) |
46.75% |
0.00% |
0.00016588 |
30% cap only (C4) |
30.00% |
0.00% |
0.00017301 |
|
30.00% |
0.00% |
0.00017516 |
|
30.00% |
20.00% |
0.00033216 |
Requiring BB individually costs almost twice the risk of every other reading on this list — not because the constraint is stricter on paper, but because it is the one reading that actually forces money into the dataset’s most volatile stock. That distinction only surfaced because the change went through the specification, where the ambiguity could be named and tested, rather than straight into the code.
The specification, the implementation and the tests never mention mathematics — that is deliberate, so the business owner can read them. But you may still need the formulation: to review the model, to document it, or to hand it to someone who thinks in constraints rather than in code. Ask at any point and Modeler derives one from the current specification and implementation, in Markdown with LaTeX, and saves it to the workspace with everything else.
Sets and indices
\(i \in \mathcal{S} = \{1, \dots, 10\}\): stocks.
\(t \in \mathcal{T} = \{1, \dots, 244\}\): historical return observation periods.
\(\mathcal{S}^{*} \subseteq \mathcal{S}\): the two stocks with the highest historical expected returns (BB and DD in this conversation’s dataset).
Parameters
\(r_{i,t}\): decimal return of stock \(i\) in period \(t\).
\(\mu_i = \frac{1}{|\mathcal{T}|} \sum_{t \in \mathcal{T}} r_{i,t}\): historical expected return of stock \(i\).
\(\Sigma_{ij} = \frac{1}{|\mathcal{T}|-1} \sum_{t \in \mathcal{T}} (r_{i,t} - \mu_i)(r_{j,t} - \mu_j)\): sample covariance of stocks \(i\) and \(j\).
\(U \in (0, 1]\): maximum weight allowed for any single stock (C4). \(U = 0.30\) in this conversation.
\(L \in [0, 1]\): minimum weight each stock in \(\mathcal{S}^{*}\) must individually hold (C5). \(L = 0.20\) in this conversation.
\(\rho\): target expected return for a point on the efficient frontier (C3, frontier only).
Decision variables
\(w_i \in \mathbb{R}\), \(i \in \mathcal{S}\): fraction of the portfolio allocated to stock \(i\).
Bounds on \(w_i\) encode C2, C4 and C5 directly, rather than as separate constraint rows:
Objective function
Minimize portfolio variance, the quadratic form \(\mathbf{w}^{\top}\Sigma\mathbf{w}\):
Since \(\Sigma\) is a positive semi-definite covariance matrix, this is a convex quadratic program and Gurobi finds the global optimum.
Constraints
C1 — Full investment.
C2 — Long-only.
Implemented as a variable lower bound (see Decision variables).
C3 — Target return (efficient frontier only).
Added parametrically per frontier point and removed after the sweep.
C4 — Maximum single-stock weight.
Implemented as a variable upper bound, \(\mathrm{ub}(w_i) = U\).
C5 — Minimum individual weight for each top-2 return stock.
A per-stock floor — cannot be satisfied by concentrating entirely in one of the two stocks. Implemented as a variable lower bound, \(\mathrm{lb}(w_i) = L\) for \(i \in \mathcal{S}^{*}\).
Complete model
The efficient frontier sweeps \(\rho\) over 20 evenly spaced points between the minimum-variance portfolio’s own expected return and \(\max_{i \in \mathcal{S}} \mu_i\).
Additional notes
The objective \(\mathbf{w}^{\top}\Sigma\mathbf{w}\) is convex because \(\Sigma\) is positive semi-definite, and every constraint is linear, so the problem is a convex QP with a guaranteed global optimum.
C2, C4 and C5 are all encoded as variable bounds rather than explicit constraint rows, which keeps the constraint matrix to a single row (C1) and lets Gurobi exploit the bounds during presolve.
Feasibility requires \(U \geq 1/|\mathcal{S}|\) (the cap must allow all stocks to collectively sum to 1), \(L \leq U\) (a top-2 floor cannot exceed the cap), and \(2L \leq 1\) (both top-2 floors together cannot exceed the full budget). With \(U = 0.30\) and \(L = 0.20\) on 10 stocks, all three hold.
Under \(U = 0.30\) and \(L = 0.20\) on BB and DD, only 6 of the 20 automatic frontier points remain feasible — reaching returns above roughly 0.84% per period would require concentrating further in BB beyond its 30% cap.
Reported volatility is \(\sigma_p = \sqrt{\mathbf{w}^{\top}\Sigma\mathbf{w}}\), the square root of the optimal objective value.
Note that this formulates the model as it stands after the Changing the model tab — with C4 and C5 both active. The original version, before that conversation, had only C1–C3. Gurobi’s own published portfolio example covers the unconstrained minimum-variance and frontier-sweep case (C1–C3); the per-stock cap and individual floor (C4, C5) are specific to this conversation.
### Sets and indices
- $i \in \mathcal{S} = \{1, \dots, 10\}$: stocks.
- $t \in \mathcal{T} = \{1, \dots, 244\}$: historical return observation periods.
- $\mathcal{S}^{*} \subseteq \mathcal{S}$: the two stocks with the highest historical expected returns (BB and DD in this conversation's dataset).
### Parameters
- $r_{i,t}$: decimal return of stock $i$ in period $t$.
- $\mu_i = \frac{1}{|\mathcal{T}|} \sum_{t \in \mathcal{T}} r_{i,t}$: historical expected return of stock $i$.
- $\Sigma_{ij} = \frac{1}{|\mathcal{T}|-1} \sum_{t \in \mathcal{T}} (r_{i,t} - \mu_i)(r_{j,t} - \mu_j)$: sample covariance of stocks $i$ and $j$.
- $U \in (0, 1]$: maximum weight allowed for any single stock (C4). $U = 0.30$ in this conversation.
- $L \in [0, 1]$: minimum weight each stock in $\mathcal{S}^{*}$ must individually hold (C5). $L = 0.20$ in this conversation.
- $\rho$: target expected return for a point on the efficient frontier (C3, frontier only).
### Decision variables
- $w_i \in \mathbb{R}$, $i \in \mathcal{S}$: fraction of the portfolio allocated to stock $i$.
Bounds on $w_i$ encode C2, C4 and C5 directly, rather than as separate constraint rows:
$$
w_i \geq \begin{cases} L & i \in \mathcal{S}^{*} \\ 0 & i \notin \mathcal{S}^{*} \end{cases}
\qquad\qquad
w_i \leq U \qquad \forall\, i \in \mathcal{S}
$$
### Objective function
Minimize portfolio variance, the quadratic form $\mathbf{w}^{\top}\Sigma\mathbf{w}$:
$$
\min_{\mathbf{w}} \; \sum_{i \in \mathcal{S}} \sum_{j \in \mathcal{S}} \Sigma_{ij}\, w_i\, w_j
$$
Since $\Sigma$ is a positive semi-definite covariance matrix, this is a convex quadratic program and Gurobi finds the global optimum.
### Constraints
**C1 — Full investment.**
$$
\sum_{i \in \mathcal{S}} w_i = 1
$$
**C2 — Long-only.**
$$
w_i \geq 0 \qquad \forall\, i \in \mathcal{S}
$$
Implemented as a variable lower bound (see Decision variables).
**C3 — Target return (efficient frontier only).**
$$
\sum_{i \in \mathcal{S}} \mu_i\, w_i = \rho
$$
Added parametrically per frontier point and removed after the sweep.
**C4 — Maximum single-stock weight.**
$$
w_i \leq U \qquad \forall\, i \in \mathcal{S}
$$
Implemented as a variable upper bound, $\mathrm{ub}(w_i) = U$.
**C5 — Minimum individual weight for each top-2 return stock.**
$$
w_i \geq L \qquad \forall\, i \in \mathcal{S}^{*}
$$
A per-stock floor — cannot be satisfied by concentrating entirely in one of the two stocks. Implemented as a variable lower bound, $\mathrm{lb}(w_i) = L$ for $i \in \mathcal{S}^{*}$.
### Complete model
$$
\begin{aligned}
\min_{\mathbf{w}} \quad & \sum_{i \in \mathcal{S}} \sum_{j \in \mathcal{S}} \Sigma_{ij}\, w_i\, w_j \\
\text{s.t.} \quad & \sum_{i \in \mathcal{S}} w_i = 1 & \text{(C1)} \\
& w_i \geq 0 & \forall\, i \in \mathcal{S} & \text{(C2)} \\
& \sum_{i \in \mathcal{S}} \mu_i\, w_i = \rho & \text{(C3, frontier only)} \\
& w_i \leq U & \forall\, i \in \mathcal{S} & \text{(C4)} \\
& w_i \geq L & \forall\, i \in \mathcal{S}^{*} & \text{(C5)}
\end{aligned}
$$
The efficient frontier sweeps $\rho$ over 20 evenly spaced points between the minimum-variance portfolio's own expected return and $\max_{i \in \mathcal{S}} \mu_i$.
### Additional notes
- The objective $\mathbf{w}^{\top}\Sigma\mathbf{w}$ is convex because $\Sigma$ is positive semi-definite, and every constraint is linear, so the problem is a convex QP with a guaranteed global optimum.
- C2, C4 and C5 are all encoded as variable bounds rather than explicit constraint rows, which keeps the constraint matrix to a single row (C1) and lets Gurobi exploit the bounds during presolve.
- Feasibility requires $U \geq 1/|\mathcal{S}|$ (the cap must allow all stocks to collectively sum to 1), $L \leq U$ (a top-2 floor cannot exceed the cap), and $2L \leq 1$ (both top-2 floors together cannot exceed the full budget). With $U = 0.30$ and $L = 0.20$ on 10 stocks, all three hold.
- Under $U = 0.30$ and $L = 0.20$ on BB and DD, only 6 of the 20 automatic frontier points remain feasible — reaching returns above roughly 0.84% per period would require concentrating further in BB beyond its 30% cap.
- Reported volatility is $\sigma_p = \sqrt{\mathbf{w}^{\top}\Sigma\mathbf{w}}$, the square root of the optimal objective value.
Note that this formulates the model as it stands after the **Changing the model** tab — with C4 and C5 both active. The original version, before that conversation, had only C1–C3. Gurobi's own [published portfolio example](https://docs.gurobi.com/projects/examples/en/current/examples/python/portfolio.html) covers the unconstrained minimum-variance and frontier-sweep case (C1–C3); the per-stock cap and individual floor (C4, C5) are specific to this conversation.