import pandas as pd
import numpy as np
import matplotlib.pyplot as pltPortfolio Management
FZ2024 Financial Modeling and Programming
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:
- Create a folder in your Google Drive named
fz2024_workshops(or similar). - 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:
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.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.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:
- Import the packages we need.
- Load a ready-made dataset with daily stock prices.
- Compute daily percentage returns for all stocks.
2.1.1 Import libraries
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
NaNbecause 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:
- Select two stocks from
ret_all. - Compute their average (expected) daily return.
- Generate random weights that sum to 1.
- 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
wearray([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_dnp.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_anp.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_daynp.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_anp.float64(0.2939679792225529)
4 Portfolio of many assets
Now we extend the same logic to many stocks at once.
Idea:
- We repeat the random-weight portfolio construction many times (for example, 10,000 simulations).
- For each simulation, we compute:
- Annual return,
- Annual standard deviation,
- Sharpe ratio,
- The vector of weights.
- 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
metricswith Return, Stdev, and Sharpe. - A DataFrame
weights_dfwith 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:
- How does the shape of the cloud of portfolios relate to the idea of diversification?
- Where would you place the risk-free asset in this graph?
How would you construct the capital allocation line? - How could you use the results of this workshop together with:
- Filter 1 (market efficiency tests),
- Filter 2 (market anomalies),
This reflection closes the three-workshop sequence and connects your Python implementation with the concepts from Rational Agent Theory, Behavioural Finance, and Market Anomalies.