Portfolio Optimization with Stock Data

Markowitz Portfolio Optimization, Efficient Frontier and Short-Selling Constraints

This notebook studies a basic Markowitz portfolio optimization problem using daily stock price data. We start by downloading price series from Yahoo Finance, transform them into daily log returns, and then estimate annualized expected returns and an annualized covariance matrix.

The notebook answers three main questions. First, we determine the efficient portfolio that achieves an annual expected return of 8% when short-selling is allowed. Second, we compute and plot the set of frontier portfolios in the expected return--volatility space. Third, we repeat the frontier calculation under a no-short-sales constraint and compare the results with the unconstrained case.

The purpose of the analysis is to illustrate the trade-off between expected return and risk. The efficient frontier shows the minimum level of risk required to achieve different target returns, while the comparison between the short-sales and no-short-sales cases shows how investment constraints affect the feasible set of portfolios.

Downloading and Transforming the Data

The first step is to download stock price data using the yfinance package. In this notebook, the data are downloaded for the period from 2020-01-01 to 2023-01-01.

From the price series we compute daily log returns:

$$ r_{i,t} = \log\left(\frac{P_{i,t}}{P_{i,t-1}}\right), $$

where $P_{i,t}$ denotes the price of asset $i$ on trading day $t$. From these daily returns we estimate the annualized expected return vector and the annualized covariance matrix:

$$ \mu = 252 \cdot \bar r, \qquad \Sigma = 252 \cdot \widehat{\operatorname{Cov}}(r_t). $$

The factor 252 is used because there are approximately 252 trading days in a year. In the optimization problems below, $\mu$ contains the annualized expected returns of the assets, while $\Sigma$ determines the annualized risk of any portfolio.

Python codeCell 1
Show code
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import yfinance as yf

from scipy.optimize import minimize

pd.set_option("display.float_format", lambda x: f"{x:.4f}")

# ------------------------------------------------------------
# SETTINGS
# ------------------------------------------------------------

# Assets used in the original assignment.
tickers = ["AAPL", "AMZN", "BRK-B", "DAX", "DOW", "NKE"]

start_date = "2020-01-01"
end_date = "2023-01-01"
trading_days = 252

# ------------------------------------------------------------
# DOWNLOAD PRICE DATA
# ------------------------------------------------------------

price_data = yf.download(
    tickers,
    start=start_date,
    end=end_date,
    auto_adjust=False,
    progress=False
)

# With several tickers, yfinance returns a DataFrame with multi-level columns.
# For the portfolio calculation we only need closing prices.
if isinstance(price_data.columns, pd.MultiIndex):
    raw_data = price_data["Close"].copy()
else:
    raw_data = price_data[["Close"]].copy()
    raw_data.columns = tickers

# Keep the downloaded tickers in the same order as the original ticker list.
available_tickers = [ticker for ticker in tickers if ticker in raw_data.columns]
missing_tickers = sorted(set(tickers) - set(available_tickers))

if missing_tickers:
    print("Missing tickers from the downloaded data:", missing_tickers)

raw_data = raw_data[available_tickers]
raw_data = raw_data.dropna(how="all").ffill().dropna()

# ------------------------------------------------------------
# LOG RETURNS AND ANNUALIZED MOMENTS
# ------------------------------------------------------------

log_returns = np.log(raw_data / raw_data.shift(1)).dropna()

expected_returns = log_returns.mean() * trading_days
cov_matrix = log_returns.cov() * trading_days

summary_table = pd.DataFrame({
    "Annualized expected return": expected_returns,
    "Annualized volatility": np.sqrt(np.diag(cov_matrix))
})

print("First rows of the price data:")
display(raw_data.head())

print("Annualized return and risk measures:")
display(summary_table)

print("Annualized covariance matrix:")
display(cov_matrix)
Output
First rows of the price data:
Ticker AAPL AMZN BRK-B DAX DOW NKE
Date
2020-01-02 75.0875 94.9005 228.3900 28.5000 53.7500 102.2000
2020-01-03 74.3575 93.7485 226.1800 27.9900 52.4200 101.9200
2020-01-06 74.9500 95.1440 226.9900 28.0050 52.2100 101.8300
2020-01-07 74.5975 95.3430 225.9200 27.9550 52.1900 101.7800
2020-01-08 75.7975 94.5985 225.9900 28.2600 52.7300 101.5500
Annualized return and risk measures:
Annualized expected return Annualized volatility
Ticker
AAPL 0.1830 0.3692
AMZN -0.0407 0.3910
BRK-B 0.1008 0.2560
DAX -0.0358 0.2844
DOW -0.0215 0.4377
NKE 0.0452 0.3772
Annualized covariance matrix:
Ticker AAPL AMZN BRK-B DAX DOW NKE
Ticker
AAPL 0.1363 0.0961 0.0565 0.0611 0.0701 0.0805
AMZN 0.0961 0.1529 0.0396 0.0503 0.0499 0.0711
BRK-B 0.0565 0.0396 0.0655 0.0523 0.0794 0.0568
DAX 0.0611 0.0503 0.0523 0.0809 0.0822 0.0668
DOW 0.0701 0.0499 0.0794 0.0822 0.1916 0.0820
NKE 0.0805 0.0711 0.0568 0.0668 0.0820 0.1423

Efficient Portfolio with an 8% Expected Return

In this part we find the portfolio that achieves an annual expected return of 8% with the lowest possible risk. Let $w=(w_1,\ldots,w_N)^{\top}$ denote the vector of portfolio weights, where $w_i$ is the weight assigned to asset $i$.

The expected return of the portfolio is

$$ \operatorname{E}(R_p) = w^{\top}\mu, $$

and its variance is

$$ \sigma_p^2 = w^{\top}\Sigma w. $$

The minimum-variance problem for the 8% target return is therefore

$$ \min_w ; w^{\top}\Sigma w $$

subject to

$$ w^{\top}\mu = 0.08, \qquad \mathbf{1}^{\top}w = 1. $$

In this block, short-selling is allowed. This means that portfolio weights may be negative. Economically, a negative weight represents a short position in the corresponding asset. Allowing short positions expands the feasible set of portfolios and may reduce the minimum achievable risk for a given target return.

Python codeCell 2
Show code
def solve_min_variance_portfolio(target_return, allow_short=True):
    # Solve the minimum-variance portfolio problem for a given target return.
    mu = expected_returns.to_numpy()
    sigma = cov_matrix.to_numpy()
    asset_names = expected_returns.index
    n_assets = len(asset_names)

    def portfolio_variance(weights):
        return float(weights.T @ sigma @ weights)

    constraints = [
        {
            "type": "eq",
            "fun": lambda weights: np.sum(weights) - 1
        },
        {
            "type": "eq",
            "fun": lambda weights: weights @ mu - target_return
        }
    ]

    bounds = None if allow_short else [(0, 1) for _ in range(n_assets)]
    initial_weights = np.repeat(1 / n_assets, n_assets)

    result = minimize(
        portfolio_variance,
        initial_weights,
        method="SLSQP",
        bounds=bounds,
        constraints=constraints,
        options={"ftol": 1e-12, "maxiter": 1000}
    )

    if not result.success:
        raise RuntimeError(f"Optimization failed: {result.message}")

    weights = pd.Series(result.x, index=asset_names, name="Weight")
    portfolio_return = float(weights @ expected_returns)
    portfolio_variance_value = portfolio_variance(weights.to_numpy())
    portfolio_volatility = np.sqrt(portfolio_variance_value)

    return {
        "weights": weights,
        "return": portfolio_return,
        "variance": portfolio_variance_value,
        "volatility": portfolio_volatility
    }


# ------------------------------------------------------------
# EFFICIENT PORTFOLIO WITH AN 8% TARGET RETURN, SHORT-SELLING ALLOWED
# ------------------------------------------------------------

target_return = 0.08
portfolio_8_short = solve_min_variance_portfolio(
    target_return=target_return,
    allow_short=True
)

weights_8_short = portfolio_8_short["weights"].to_frame()
weights_8_short["Weight, %"] = 100 * weights_8_short["Weight"]

performance_8_short = pd.DataFrame({
    "Measure": ["Target return", "Achieved expected return", "Variance", "Volatility"],
    "Value": [
        target_return,
        portfolio_8_short["return"],
        portfolio_8_short["variance"],
        portfolio_8_short["volatility"]
    ]
})

print("Optimal portfolio weights for an 8% target return, with short-selling:")
display(weights_8_short)

print("Portfolio performance measures:")
display(performance_8_short)
Output
Optimal portfolio weights for an 8% target return, with short-selling:
Weight Weight, %
Ticker
AAPL 0.0046 0.4576
AMZN 0.1082 10.8201
BRK-B 0.8704 87.0367
DAX 0.2367 23.6736
DOW -0.2137 -21.3716
NKE -0.0062 -0.6163
Portfolio performance measures:
Measure Value
0 Target return 0.0800
1 Achieved expected return 0.0800
2 Variance 0.0560
3 Volatility 0.2367

Frontier Portfolios in the Expected Return--Risk Space

The set of frontier portfolios is obtained by solving the same minimum-variance problem for many different target returns $r^*$ rather than for only one fixed target return.

For each target return, we solve

$$ \min_w ; w^{\top}\Sigma w $$

subject to

$$ w^{\top}\mu = r^*, \qquad \mathbf{1}^{\top}w = 1. $$

Each target return corresponds to a portfolio with the lowest possible variance among all portfolios that achieve that return. Plotting the resulting pairs $(\sigma_p,\operatorname{E}(R_p))$ gives the frontier in the expected return--volatility space.

When short-selling is allowed, the portfolio weights are not restricted by sign. This makes the feasible set larger, so the minimum variance for a given target return can be lower than under a no-short-sales constraint.

Python codeCell 3
Show code
def build_frontier(target_returns, allow_short=True):
    # Compute frontier portfolios over a grid of target returns.
    rows = []

    for target in target_returns:
        try:
            solution = solve_min_variance_portfolio(
                target_return=target,
                allow_short=allow_short
            )

            rows.append({
                "target_return": target,
                "expected_return": solution["return"],
                "volatility": solution["volatility"],
                "variance": solution["variance"]
            })
        except RuntimeError:
            # Some targets may be infeasible, especially under no-short-sales constraints.
            # Skipping infeasible points makes the function usable in both cases.
            continue

    return pd.DataFrame(rows)


# ------------------------------------------------------------
# FRONTIER PORTFOLIOS WITH SHORT-SELLING
# ------------------------------------------------------------

min_target = expected_returns.min() - 0.10
max_target = expected_returns.max() + 0.10
frontier_targets_short = np.linspace(min_target, max_target, 200)

frontier_short = build_frontier(
    target_returns=frontier_targets_short,
    allow_short=True
)

plt.figure(figsize=(8, 5))
plt.plot(
    frontier_short["volatility"],
    frontier_short["expected_return"],
    label="Frontier with short-selling"
)
plt.scatter(
    portfolio_8_short["volatility"],
    portfolio_8_short["return"],
    marker="o",
    label="8% target return"
)
plt.axhline(target_return, linestyle="--", linewidth=1)

plt.xlabel("Annualized volatility")
plt.ylabel("Annualized expected return")
plt.title("Frontier portfolios with short-selling")
plt.grid(True, linestyle="--", alpha=0.6)
plt.legend()
plt.show()
Output
Notebook output image

Frontier Portfolios without Short-Selling

We now solve the same portfolio optimization problem, but we impose a no-short-sales constraint. Mathematically, this means that all portfolio weights must be nonnegative:

$$ w_i \geq 0, \qquad i = 1,\ldots,N. $$

The constrained optimization problem is

$$ \min_w ; w^{\top}\Sigma w $$

subject to

$$ w^{\top}\mu = r^*, \qquad \mathbf{1}^{\top}w = 1, \qquad w_i \geq 0 \quad \text{for all } i. $$

Under this restriction, the investor can only hold positive or zero positions in the assets. The feasible set is therefore smaller than in the unconstrained case. As a result, the same target return usually requires at least as much risk as in the short-selling case, and often more. In addition, without short-selling, feasible target returns must lie between the smallest and largest individual expected returns, because the portfolio return is a convex combination of the asset returns.

Python codeCell 4
Show code
# ------------------------------------------------------------
# EFFICIENT PORTFOLIO WITH AN 8% TARGET RETURN, NO SHORT-SELLING
# ------------------------------------------------------------

portfolio_8_no_short = solve_min_variance_portfolio(
    target_return=target_return,
    allow_short=False
)

weights_8_no_short = portfolio_8_no_short["weights"].to_frame()
weights_8_no_short["Weight, %"] = 100 * weights_8_no_short["Weight"]

performance_8_no_short = pd.DataFrame({
    "Measure": ["Target return", "Achieved expected return", "Variance", "Volatility"],
    "Value": [
        target_return,
        portfolio_8_no_short["return"],
        portfolio_8_no_short["variance"],
        portfolio_8_no_short["volatility"]
    ]
})

print("Optimal portfolio weights for an 8% target return, without short-selling:")
display(weights_8_no_short)

print("Portfolio performance measures:")
display(performance_8_no_short)

# ------------------------------------------------------------
# FRONTIER PORTFOLIOS WITHOUT SHORT-SELLING
# ------------------------------------------------------------

frontier_targets_no_short = np.linspace(
    expected_returns.min(),
    expected_returns.max(),
    200
)

frontier_no_short = build_frontier(
    target_returns=frontier_targets_no_short,
    allow_short=False
)

plt.figure(figsize=(8, 5))
plt.plot(
    frontier_no_short["volatility"],
    frontier_no_short["expected_return"],
    label="Frontier without short-selling"
)
plt.scatter(
    portfolio_8_no_short["volatility"],
    portfolio_8_no_short["return"],
    marker="o",
    label="8% target return"
)
plt.axhline(target_return, linestyle="--", linewidth=1)

plt.xlabel("Annualized volatility")
plt.ylabel("Annualized expected return")
plt.title("Frontier portfolios without short-selling")
plt.ylim(expected_returns.min() - 0.02, expected_returns.max() + 0.02)
plt.grid(True, linestyle="--", alpha=0.6)
plt.legend()
plt.show()
Output
Optimal portfolio weights for an 8% target return, without short-selling:
Weight Weight, %
Ticker
AAPL 0.0556 5.5579
AMZN 0.0981 9.8130
BRK-B 0.7623 76.2315
DAX 0.0840 8.3976
DOW 0.0000 0.0000
NKE 0.0000 0.0000
Portfolio performance measures:
Measure Value
0 Target return 0.0800
1 Achieved expected return 0.0800
2 Variance 0.0604
3 Volatility 0.2458
Notebook output image
Python codeCell 5
Show code
# ------------------------------------------------------------
# COMPARISON OF SHORT-SELLING AND NO-SHORT-SALES RESULTS
# ------------------------------------------------------------

comparison_table = pd.DataFrame({
    "With short-selling": [
        portfolio_8_short["return"],
        portfolio_8_short["variance"],
        portfolio_8_short["volatility"]
    ],
    "Without short-selling": [
        portfolio_8_no_short["return"],
        portfolio_8_no_short["variance"],
        portfolio_8_no_short["volatility"]
    ]
}, index=["Achieved expected return", "Variance", "Volatility"])

comparison_table["Difference, no-short minus short"] = (
    comparison_table["Without short-selling"] - comparison_table["With short-selling"]
)

weights_comparison = pd.DataFrame({
    "With short-selling": portfolio_8_short["weights"],
    "Without short-selling": portfolio_8_no_short["weights"]
})
weights_comparison["Difference, no-short minus short"] = (
    weights_comparison["Without short-selling"] - weights_comparison["With short-selling"]
)

print("Comparison of the portfolios with an 8% target return:")
display(comparison_table)

print("Comparison of portfolio weights:")
display(weights_comparison)

plt.figure(figsize=(8, 5))
plt.plot(
    frontier_short["volatility"],
    frontier_short["expected_return"],
    label="With short-selling"
)
plt.plot(
    frontier_no_short["volatility"],
    frontier_no_short["expected_return"],
    label="Without short-selling"
)
plt.scatter(
    portfolio_8_short["volatility"],
    portfolio_8_short["return"],
    marker="o",
    label="8%, with short-selling"
)
plt.scatter(
    portfolio_8_no_short["volatility"],
    portfolio_8_no_short["return"],
    marker="x",
    label="8%, without short-selling"
)
plt.axhline(target_return, linestyle="--", linewidth=1)

plt.xlabel("Annualized volatility")
plt.ylabel("Annualized expected return")
plt.title("Comparison of short-selling and no-short-sales frontiers")
plt.grid(True, linestyle="--", alpha=0.6)
plt.legend()
plt.show()
Output
Comparison of the portfolios with an 8% target return:
With short-selling Without short-selling Difference, no-short minus short
Achieved expected return 0.0800 0.0800 0.0000
Variance 0.0560 0.0604 0.0044
Volatility 0.2367 0.2458 0.0091
Comparison of portfolio weights:
With short-selling Without short-selling Difference, no-short minus short
Ticker
AAPL 0.0046 0.0556 0.0510
AMZN 0.1082 0.0981 -0.0101
BRK-B 0.8704 0.7623 -0.1081
DAX 0.2367 0.0840 -0.1528
DOW -0.2137 0.0000 0.2137
NKE -0.0062 0.0000 0.0062
Notebook output image