Portfolio Management

FZ2024 Financial Modeling and Programming

Author
Affiliation

Sergio Castellanos-Gamboa, PhD

Tecnológico de Monterrey

Published

November 28, 2025

0.1 Before you begin: important instructions for all Workshops

Welcome to our workshop series! Please read these instructions carefully before starting any activity.
Following these guidelines will make your work smoother and ensure that your submissions are graded without issues.

0.1.1 Working environment

We will use Google Colab for all workshops. Colab runs Python in the cloud — you don’t need to install anything locally.

  • Access Colab at: https://colab.research.google.com/
  • Sign in with your institutional Google account for access to all features.
  • Always save a copy of the notebook to your Google Drive:
    • Go to File → Save a copy in Drive.

0.1.2 Loading data

You may work with datasets provided by the instructor or public datasets online.
You will receive instructions each time to load the data with Python code.
However, it is a good idea to store files, like data or your own notes, in a dedicated Google Drive folder:

  1. Create a folder in your Google Drive named fz2024_workshops (or similar).
  2. Upload your datasets there.

0.1.3 Output and submission format

  • After completing the workshop, export your notebook as PDF:
    • In Colab: File → Print → Save as PDF.
  • Submit the PDF file through Canvas, as well as the .ipynb.
  • Include all outputs, tables, and graphs in your PDF — make sure you run all cells before exporting.
  • Name your PDF file using the following format: Lastname_Firstname_WorkshopX.pdf.

0.1.4 Deadlines

All assignments must be uploaded to Canvas before the stated deadline.
Late submissions are not accepted.
Once you have read and understood these instructions, you are ready to begin the workshop!


1 Overview

This workshop is the third of three workshops that together explore how to build a portfolio grounded in Rational Agent Theory, Behavioural Finance, and the Market Anomalies literature.
The process unfolds in three analytical stages:

  1. Filter 1 – Market Efficiency
    Test whether each stock’s historical information helps forecast its price.
    This step identifies inefficient markets, where past returns contain predictive signals.

  2. Filter 2 – Market Anomalies
    Examine systematic patterns—such as momentum or trend-following behaviour—that contradict the Efficient Market Hypothesis.
    Here, the strategy buys assets with upward trends and sells those trending downward.

  3. Filter 3 – Portfolio Allocation
    Optimize the portfolio composition using only the assets that pass the previous filters.

Unlike the traditional Markowitz (1952) framework, which focuses solely on optimizing asset weights, this three-part approach first conducts stock selection through theoretical filters. Each filter reflects assumptions derived from the rational agent and behavioural perspectives, allowing you to connect empirical testing with economic theory before moving into optimization.

In this workshop, you will implement Filter 3 – Portfolio Allocation using a Markowitz-style portfolio.

2 Portfolio management: from single stocks to portfolios

In previous workshops, you analysed individual stocks and looked for possible market anomalies.
Now we move to the portfolio level:

  • Instead of asking “Is this stock attractive?”, we ask “How should I combine several stocks?”
  • We will use:
    • Daily returns,
    • Expected returns (means),
    • Variance–covariance matrix,
    • Random portfolio weights,
    • Sharpe ratio as a performance measure.

We follow the classical Markowitz (1952) framework but implemented in Python.

2.1 Setup: packages and data

In this section we:

  1. Import the packages we need.
  2. Load a ready-made dataset with daily stock prices.
  3. Compute daily percentage returns for all stocks.

2.1.1 Import libraries

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
  • pandas: to work with tables (DataFrames).
  • numpy: to do matrix operations and numerical calculations.
  • matplotlib: to create plots.

2.1.2 Load prices data

We will use the filtered stocks from the previous stage of the project.
If you did not complete the previous workshop, you can still run this one because the data are stored online as a .csv file.

# If you did not create the file in the previous workshop,
# this line downloads the data directly from GitHub.
data = pd.read_csv(
    "https://raw.githubusercontent.com/abernal30/AFP_py/refs/heads/main/data/portfolio.csv",
    index_col=0
)

data.head()
ABT.Close BAC.PE.Close C.PJ.Close NVS.Close UNH.Close WFC.PL.Close CSCO.Close JPM.PD.Close WFC.PQ.Close MCD.Close ... BML.PG.Close TCTZF.Close TCEHY.Close CVX.Close SHEL.Close AVGO.Close INTC.Close NVSEF.Close ADBE.Close TMUS.Close
date
01/02/2020 86.949997 24.511700 28.570000 94.949997 292.500000 1452.510010 48.419998 27.639999 27.629999 200.789993 ... 21.480000 49.880001 49.880001 121.430000 59.740002 322.390015 60.840000 94.800003 334.429993 78.589996
01/03/2020 85.889999 24.600000 28.719999 94.790001 289.540009 1457.000000 47.630001 27.660000 27.780001 200.080002 ... 21.420000 48.930000 49.029999 121.010002 60.209999 314.190002 60.099998 93.250000 331.809998 78.169998
01/06/2020 86.339996 24.497101 28.719999 95.430000 291.549988 1467.469971 47.799999 27.580000 27.690001 202.330002 ... 21.469999 48.700001 48.770000 120.599998 60.959999 313.720001 59.930000 94.349998 333.709991 78.620003
01/07/2020 85.860001 24.510000 28.629999 94.480003 289.790009 1473.500000 47.490002 27.500000 27.500000 202.630005 ... 21.429300 49.770000 49.779999 119.059998 60.400002 312.640015 58.930000 95.000000 333.390015 78.919998
01/08/2020 86.209999 24.440001 28.709999 94.480003 295.899994 1480.000000 47.520000 27.549999 27.559999 205.910004 ... 21.930000 49.650002 49.650002 117.699997 59.689999 308.739990 58.970001 94.720001 337.869995 79.419998

5 rows × 34 columns

Each column is the closing price of one stock and each row is a trading day, identified by the date index.

2.2 From prices to returns

Markowitz portfolios are built with returns, not prices. We will compute daily percentage returns using pct_change().

Mathematically, for price P_t:

R_t = \frac{P_t - P_{t-1}}{P_{t-1}}= \frac{P_t}{P_{t-1}}-1

Now we compute the daily returns for all stocks

# Daily percentage returns for all stocks
ret_all = data.pct_change()
ret_all.head()
ABT.Close BAC.PE.Close C.PJ.Close NVS.Close UNH.Close WFC.PL.Close CSCO.Close JPM.PD.Close WFC.PQ.Close MCD.Close ... BML.PG.Close TCTZF.Close TCEHY.Close CVX.Close SHEL.Close AVGO.Close INTC.Close NVSEF.Close ADBE.Close TMUS.Close
date
01/02/2020 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
01/03/2020 -0.012191 0.003602 0.005250 -0.001685 -0.010120 0.003091 -0.016316 0.000724 0.005429 -0.003536 ... -0.002793 -0.019046 -0.017041 -0.003459 0.007867 -0.025435 -0.012163 -0.016350 -0.007834 -0.005344
01/06/2020 0.005239 -0.004183 0.000000 0.006752 0.006942 0.007186 0.003569 -0.002892 -0.003240 0.011246 ... 0.002334 -0.004701 -0.005303 -0.003388 0.012456 -0.001496 -0.002829 0.011796 0.005726 0.005757
01/07/2020 -0.005559 0.000527 -0.003134 -0.009955 -0.006037 0.004109 -0.006485 -0.002901 -0.006862 0.001483 ... -0.001896 0.021971 0.020709 -0.012769 -0.009186 -0.003443 -0.016686 0.006889 -0.000959 0.003816
01/08/2020 0.004076 -0.002856 0.002794 0.000000 0.021084 0.004411 0.000632 0.001818 0.002182 0.016187 ... 0.023365 -0.002411 -0.002611 -0.011423 -0.011755 -0.012474 0.000679 -0.002947 0.013438 0.006336

5 rows × 34 columns

  • The first row is NaN because there is no previous day to compare with.
  • Each column now contains daily returns instead of prices.

3 Portfolio of two stocks

We start with a simple example: a portfolio with two stocks only.

We will:

  1. Select two stocks from ret_all.
  2. Compute their average (expected) daily return.
  3. Generate random weights that sum to 1.
  4. Compute:
    • Portfolio expected return,
    • Portfolio variance and standard deviation,
    • Annualized return and risk, and
    • Sharpe’s ratio.

3.1 Select two stocks

We choose CMCSA.Close and TMUS.Close as an example.

ret_two = ret_all.loc[:, ("CMCSA.Close", "TMUS.Close")]
ret_two.head()
CMCSA.Close TMUS.Close
date
01/02/2020 NaN NaN
01/03/2020 -0.007935 -0.005344
01/06/2020 -0.007554 0.005757
01/07/2020 0.005821 0.003816
01/08/2020 0.010238 0.006336

3.2 Expected daily return for each stock

The arithmetic mean of the daily returns is our estimator of the expected daily return:

\mathbb{E}[R_i] \approx \bar{R}_i = \frac{1}{T} \sum_{t=1}^T R_{i,t}.

ret_two.mean()
CMCSA.Close    0.000147
TMUS.Close     0.001091
dtype: float64

The output shows the average daily return for each stock.

3.3 Random portfolio weights

We now generate random weights for the two stocks.

  • We first draw two random numbers.
  • Then we normalize them so that they sum to 1 (or 100%).
np.random.seed(1)  # To get the same random numbers each time

ran = np.random.rand(2)   # two random numbers, one per stock

# Make the weights sum to 1
su = np.sum(ran)
we = ran / su    # these are the portfolio weights
we
array([0.36666223, 0.63333777])

Check that the sum of the weights is exactly 1:

sum(we)
np.float64(1.0)

3.4 Portfolio expected return (daily and annual)

The (daily) expected return of a two-asset portfolio is:

\mathbb{E}[R_p] = w_A \, \mathbb{E}[R_A] + w_B \, \mathbb{E}[R_B],

where:

  • w_A, w_B are the portfolio weights,
  • \mathbb{E}[R_A], \mathbb{E}[R_B] are the expected returns of each stock.

First, we compute it manually:

0.000147 * we[0] + 0.001091 * we[1]
np.float64(0.0007448708537095845)

Then we compute the same thing using a more general Python expression (works for 2 or more assets):

rend_d = np.sum(ret_two.mean() * we)  # daily expected return
rend_d
np.float64(0.0007447264667679712)

Now we annualize the expected return, assuming 252 trading days per year:

R_{\text{annualized}}=(1 + R_{\text{daily}})^{252} - 1.

rend_a = (1 + rend_d)**252 - 1
rend_a
np.float64(0.20635235323618217)

3.5 Portfolio variance and standard deviation

For two assets, the portfolio variance is:

\sigma_p^2 = w_A^2 \sigma_A^2 + w_B^2 \sigma_B^2 + 2 w_A w_B \, \text{Cov}(R_A, R_B).

We first compute the covariance matrix of returns:

cov = ret_two.cov()
cov
CMCSA.Close TMUS.Close
CMCSA.Close 0.000386 0.000237
TMUS.Close 0.000237 0.000451

Now we compute the daily portfolio variance manually:

0.000386 * pow(we[0], 2) + 0.000451 * pow(we[1], 2) + 2 * 0.000237 * we[0] * we[1]
np.float64(0.00034287071765920073)

And then with a general Python formula that works for any number of assets:

varp_day = np.dot(np.dot(we, cov), we.T)
varp_day
np.float64(0.0003429252889213939)

To get the annual portfolio standard deviation, we do:

\sigma_{\text{annual}} = \sqrt{\sigma_{\text{daily}}^2} \times \sqrt{252}.

desvp_a = np.sqrt(varp_day) * np.sqrt(252)
desvp_a
np.float64(0.2939679792225529)

3.6 Sharpe’s ratio

The Sharpe’s ratio measures the excess return per unit of risk:

\text{Sharpe} = \frac{R_p - r_f}{\sigma_p},

where:

  • R_p is the annual portfolio return,
  • r_f is the risk-free rate (we assume r_f = 0),
  • \sigma_p is the annual standard deviation.
r = 0  # risk-free rate
sharpe = (rend_a - r) / desvp_a
sharpe
np.float64(0.7019552053999731)
  • Is this Sharpe ratio high or low?
  • How would you compare it to another portfolio?

4 Portfolio of many assets

Now we extend the same logic to many stocks at once.

Idea:

  1. We repeat the random-weight portfolio construction many times (for example, 10,000 simulations).
  2. For each simulation, we compute:
    • Annual return,
    • Annual standard deviation,
    • Sharpe ratio,
    • The vector of weights.
  3. We then collect everything into a DataFrame.

This will give us a cloud of portfolios from which we can approximate the efficient frontier.

4.1 Monte Carlo simulation of random portfolios

retp = []    # portfolio returns
sdp = []     # portfolio standard deviations
sharp = []   # Sharpe ratios
wep = []     # list of weight vectors

n = 10000    # number of simulations
r = 0        # risk-free rate

for i in range(n):
    # Random weights for all columns (all stocks)
    ran = np.random.rand(ret_all.shape[1])
    su = np.sum(ran)
    we = ran / su

    # Daily and annual expected returns
    rend_d = np.sum(ret_all.mean() * we)
    rend_a = (1 + rend_d) ** 252 - 1

    # Covariance and annual standard deviation
    cov = ret_all.cov()
    varp_day = np.dot(np.dot(we, cov), we.T)
    desvp_a = np.sqrt(varp_day) * np.sqrt(252)

    # Sharpe ratio
    sharpe = (rend_a - r) / desvp_a

    # Save results
    retp.append(rend_a)
    sdp.append(desvp_a)
    sharp.append(sharpe)
    wep.append(we)

4.2 DataFrames for metrics and weights

We now create:

  • A DataFrame metrics with Return, Stdev, and Sharpe.
  • A DataFrame weights_df with one column per stock and one row per simulation.
metrics = pd.DataFrame({"Return": retp, "Stdev": sdp, "Sharpe": sharp})
metrics.head()
Return Stdev Sharpe
0 0.082503 0.211402 0.390267
1 0.085297 0.199864 0.426775
2 0.087777 0.203849 0.430599
3 0.069707 0.199730 0.349008
4 0.088389 0.201766 0.438078
weights_df = pd.DataFrame(wep, columns=ret_all.columns)
weights_df.head()
ABT.Close BAC.PE.Close C.PJ.Close NVS.Close UNH.Close WFC.PL.Close CSCO.Close JPM.PD.Close WFC.PQ.Close MCD.Close ... BML.PG.Close TCTZF.Close TCEHY.Close CVX.Close SHEL.Close AVGO.Close INTC.Close NVSEF.Close ADBE.Close TMUS.Close
0 0.000008 0.020231 0.009820 0.006179 0.012464 0.023124 0.026550 0.036056 0.028051 0.045853 ... 0.005691 0.002613 0.011364 0.058762 0.006581 0.028179 0.064099 0.035678 0.046298 0.021113
1 0.043126 0.052432 0.001149 0.047125 0.062121 0.047000 0.017618 0.049583 0.006485 0.028137 ... 0.006429 0.026011 0.043623 0.026019 0.003138 0.033665 0.041700 0.032346 0.059340 0.036848
2 0.048349 0.007357 0.007454 0.043211 0.021283 0.008850 0.049639 0.018612 0.040183 0.038854 ... 0.021843 0.012685 0.048348 0.030703 0.000154 0.033029 0.017482 0.028207 0.047414 0.019121
3 0.052457 0.035991 0.000913 0.053664 0.039891 0.057583 0.009951 0.007918 0.053846 0.040233 ... 0.007169 0.016119 0.033820 0.055982 0.032393 0.001077 0.046227 0.013451 0.046600 0.022394
4 0.057787 0.049996 0.037223 0.009131 0.004010 0.008120 0.002981 0.007193 0.015104 0.047712 ... 0.041487 0.055474 0.010492 0.001243 0.004686 0.032546 0.040575 0.038067 0.021237 0.066157

5 rows × 34 columns

Check that each row of weights_df sums to 1 (weights for each simulation):

weights_df.iloc[0, :].sum()
np.float64(1.0)

Now we concatenate both DataFrames into one large DataFrame called port:

port = pd.concat([metrics, weights_df], axis=1)
port.head()
Return Stdev Sharpe ABT.Close BAC.PE.Close C.PJ.Close NVS.Close UNH.Close WFC.PL.Close CSCO.Close ... BML.PG.Close TCTZF.Close TCEHY.Close CVX.Close SHEL.Close AVGO.Close INTC.Close NVSEF.Close ADBE.Close TMUS.Close
0 0.082503 0.211402 0.390267 0.000008 0.020231 0.009820 0.006179 0.012464 0.023124 0.026550 ... 0.005691 0.002613 0.011364 0.058762 0.006581 0.028179 0.064099 0.035678 0.046298 0.021113
1 0.085297 0.199864 0.426775 0.043126 0.052432 0.001149 0.047125 0.062121 0.047000 0.017618 ... 0.006429 0.026011 0.043623 0.026019 0.003138 0.033665 0.041700 0.032346 0.059340 0.036848
2 0.087777 0.203849 0.430599 0.048349 0.007357 0.007454 0.043211 0.021283 0.008850 0.049639 ... 0.021843 0.012685 0.048348 0.030703 0.000154 0.033029 0.017482 0.028207 0.047414 0.019121
3 0.069707 0.199730 0.349008 0.052457 0.035991 0.000913 0.053664 0.039891 0.057583 0.009951 ... 0.007169 0.016119 0.033820 0.055982 0.032393 0.001077 0.046227 0.013451 0.046600 0.022394
4 0.088389 0.201766 0.438078 0.057787 0.049996 0.037223 0.009131 0.004010 0.008120 0.002981 ... 0.041487 0.055474 0.010492 0.001243 0.004686 0.032546 0.040575 0.038067 0.021237 0.066157

5 rows × 37 columns

4.3 Best Sharpe and minimum variance portfolios

We can sort the portfolios by Sharpe to find the one with highest risk-adjusted performance:

port.sort_values(by="Sharpe", ascending=False).head()
Return Stdev Sharpe ABT.Close BAC.PE.Close C.PJ.Close NVS.Close UNH.Close WFC.PL.Close CSCO.Close ... BML.PG.Close TCTZF.Close TCEHY.Close CVX.Close SHEL.Close AVGO.Close INTC.Close NVSEF.Close ADBE.Close TMUS.Close
2194 0.133390 0.220914 0.603808 0.060100 0.026012 0.000724 0.016092 0.065260 0.013263 0.000725 ... 0.024553 0.059973 0.024080 0.048028 0.028587 0.039303 0.001541 0.035531 0.060852 0.054208
5105 0.122734 0.212432 0.577759 0.023532 0.003933 0.007303 0.012715 0.040051 0.022824 0.010855 ... 0.048415 0.018426 0.032553 0.024174 0.008024 0.054705 0.007951 0.072108 0.020181 0.055750
4525 0.126759 0.221490 0.572303 0.051738 0.058434 0.004115 0.013350 0.051657 0.015255 0.005145 ... 0.007238 0.043937 0.017001 0.041501 0.057769 0.055592 0.001705 0.011898 0.041471 0.023642
5493 0.125254 0.224878 0.556988 0.063846 0.006244 0.053774 0.016164 0.029075 0.042824 0.025919 ... 0.034731 0.048187 0.004463 0.055016 0.027151 0.051093 0.017966 0.013418 0.038909 0.058051
1064 0.120145 0.216219 0.555662 0.038664 0.016529 0.004812 0.039426 0.053011 0.002893 0.015648 ... 0.005529 0.016895 0.042629 0.057718 0.041792 0.049400 0.000767 0.047744 0.057328 0.028131

5 rows × 37 columns

We can also sort by Stdev to find portfolios with minimum volatility:

port.sort_values(by="Stdev", ascending=True).head()
Return Stdev Sharpe ABT.Close BAC.PE.Close C.PJ.Close NVS.Close UNH.Close WFC.PL.Close CSCO.Close ... BML.PG.Close TCTZF.Close TCEHY.Close CVX.Close SHEL.Close AVGO.Close INTC.Close NVSEF.Close ADBE.Close TMUS.Close
4723 0.037110 0.177020 0.209636 0.012935 0.046121 0.041635 0.053169 0.003635 0.037210 0.053407 ... 0.036142 0.028928 0.000472 0.015257 0.025678 0.003587 0.042142 0.022495 0.007167 0.050113
569 0.040188 0.179437 0.223966 0.011899 0.050068 0.048099 0.050647 0.001298 0.036971 0.006883 ... 0.028450 0.007434 0.000194 0.012675 0.036242 0.021755 0.052299 0.064682 0.013844 0.005321
7264 0.032687 0.179518 0.182079 0.013117 0.005150 0.024820 0.033997 0.001547 0.033566 0.046599 ... 0.036179 0.052928 0.019034 0.001056 0.006132 0.008046 0.054940 0.000863 0.010824 0.005705
1304 0.048430 0.179751 0.269430 0.050764 0.046450 0.031353 0.030107 0.003902 0.049009 0.029101 ... 0.038106 0.028904 0.004484 0.000669 0.004968 0.050967 0.028446 0.027874 0.008319 0.009886
5002 0.060254 0.180208 0.334360 0.014118 0.072643 0.052631 0.033768 0.024970 0.001550 0.041626 ... 0.047688 0.028133 0.006630 0.005170 0.038948 0.018006 0.003740 0.079384 0.039661 0.076350

5 rows × 37 columns

  • Compare the weights of the maximum Sharpe portfolio and the minimum variance portfolio.
  • Are they concentrated in the same assets?
  • How different are their expected returns?

4.4 Plotting the efficient frontier

Finally, we want to visualize all these simulated portfolios in a risk–return space:

  • Horizontal axis: Standard deviation (risk).
  • Vertical axis: Expected return.
  • Colour: Sharpe ratio.

This gives us a picture of the efficient frontier.

The following function (adapted from McKinney, 2017) takes a DataFrame like port and plots the cloud of portfolios, highlighting:

  • The maximum Sharpe portfolio (in green),
  • The minimum volatility portfolio (in red).
def frontef(data):
    # this is a python function, do not change any of the content, just run the Python cell
    """
    Return the plot of the efficient frontier

    Parameters
    -----------
    data: is a data frame that is the result of aplying the function portn
    """

    max_sharpe_port = data.iloc[data["Sharpe"].idxmax(), ]
    min_vol_port = data.iloc[data["Stdev"].idxmin(), ]

    plt.subplots(figsize=(12, 5))
    plt.scatter(data["Stdev"], data["Return"], c=data["Sharpe"], cmap="RdYlBu")
    plt.xlabel("Standard Deviation")
    plt.ylabel("Returns")
    plt.title("Efficient frontier")
    plt.colorbar()
    plt.scatter(
        max_sharpe_port.loc["Stdev",],
        max_sharpe_port.loc["Return",],
        marker=(5, 1, 0),
        color="g",
        s=500,
    )
    plt.scatter(
        min_vol_port.loc["Stdev",],
        min_vol_port.loc["Return",],
        marker=(5, 1, 0),
        color="r",
        s=500,
    )
    plt.show()

Now we call the function using our port DataFrame:

frontef(port)

4.5 Interpretation and connection with theory

Add a final Markdown cell and answer briefly:

  1. How does the shape of the cloud of portfolios relate to the idea of diversification?
  2. Where would you place the risk-free asset in this graph?
    How would you construct the capital allocation line?
  3. How could you use the results of this workshop together with:
    • Filter 1 (market efficiency tests),
    • Filter 2 (market anomalies),
    to design a portfolio that is both theoretically grounded and empirically robust?

This reflection closes the three-workshop sequence and connects your Python implementation with the concepts from Rational Agent Theory, Behavioural Finance, and Market Anomalies.