⚠ Trading involves significant risk of loss. Everything on this site is for education only and is not financial advice.
Lesson 11 of 13 · Free

Portfolio Analysis in pandas: Correlation and Covariance Matrices, Weighted Portfolio Volatility and a Rebalancing Simulation

Compute annualised covariance and correlation matrices from returns, derive portfolio volatility and risk contributions with NumPy, and simulate drift versus calendar rebalancing.

Author: Moustafa HijaziContent reviewed: 2026-10-306 min readSelf-study course

What you will learn

  • How to align several price series into one returns DataFrame and compute .cov() and .corr() correctly annualised.
  • The matrix formula for portfolio volatility, w.T @ Σ @ w, and how to read each asset's contribution to risk.
  • How to simulate a buy-and-hold portfolio that drifts versus one rebalanced on a calendar.
  • Which numerical traps (misaligned dates, mixed currencies, look-ahead in the covariance window) produce wrong answers.

Explanation

Correlation and portfolio heat (Risk & Trade Management, lesson 6), combining two systems (Systematic Trading, lesson 10) and rebalancing rules (ETFs, lesson 10) were taught elsewhere; this lesson implements them for any number of assets in code.

From prices to a returns matrix

Load each instrument's adjusted close (lesson 4) into one DataFrame, one column per ticker: prices = pd.concat(series_dict, axis=1). Holidays that differ between markets create NaN rows; decide explicitly between prices.dropna() (common days only) and prices.ffill() (carry the last price forward). Then rets = prices.pct_change().dropna(). Convert everything to one currency first; a US ETF held by a euro investor must be translated with the EUR/USD series, or its volatility and correlations describe the wrong thing.

Covariance, correlation and annualisation

rets.cov() is the sample covariance matrix of daily returns; multiply by 252 to annualise (variance scales with time). rets.corr() is unit-free and needs no scaling. The diagonal holds each asset's variance, so np.sqrt(np.diag(cov_a)) gives annualised volatilities. The relation Σ_ij = ρ_ij × σ_i × σ_j lets you build a matrix from volatilities and correlations to test a hypothetical scenario such as "correlations rise to 0.8 in a crisis".

Portfolio volatility and risk contributions

  • w = np.array([0.5, 0.3, 0.2]) # weights, sum to 1
  • cov_a = rets.cov().values * 252
  • port_var = w @ cov_a @ w
  • port_vol = np.sqrt(port_var)
  • marginal = cov_a @ w # d(var)/d(w)
  • risk_contrib = w * marginal / port_var # shares that sum to 1

@ is matrix multiplication. risk_contrib tells you what fraction of the portfolio's variance each asset is responsible for; it is usually far from the capital weight, and that gap is the starting point of risk budgeting (Portfolio Construction, lesson 1).

A rebalancing simulation

Buy-and-hold lets weights drift: winners grow into a larger share. Calendar rebalancing resets to targets at fixed dates. In pandas, resample returns to the rebalancing frequency and loop over the periods:

  • period_rets = (1 + rets).resample('ME').prod() - 1 # monthly returns
  • target = np.array([0.6, 0.4]); hold = 10_000 * target; path = []
  • for _, r in period_rets.iterrows():
  • hold = hold * (1 + r.values) # grow each sleeve
  • path.append(hold.sum())
  • hold = hold.sum() * target # rebalance to targets

For the drifting version, delete the last line. Use 'QE' for quarterly and 'YE' for yearly (pandas 2.2 and later; older versions use 'M', 'Q', 'A'). Turnover of a rebalance is abs(new_hold - old_hold).sum(); multiply by a cost rate such as 0.1 % and subtract. In a German taxable account rebalancing also realises gains; check the tax effect with a Steuerberater.

Worked example

Hypothetical example with invented inputs; fees, spreads and taxes excluded.

Part 1: three-asset volatility. Annualised volatilities 20 %, 10 %, 15 %; correlations ρ₁₂ = 0.2, ρ₁₃ = 0.6, ρ₂₃ = 0.1; weights 50 / 30 / 20 %.

  1. Covariances: Σ₁₂ = 0.2 × 0.20 × 0.10 = 0.0040; Σ₁₃ = 0.6 × 0.20 × 0.15 = 0.0180; Σ₂₃ = 0.1 × 0.10 × 0.15 = 0.0015. Diagonal: 0.0400, 0.0100, 0.0225.
  2. Portfolio variance = Σ wᵢ wⱼ Σᵢⱼ = 0.25 × 0.04 + 0.09 × 0.01 + 0.04 × 0.0225 + 2 × (0.5 × 0.3 × 0.004 + 0.5 × 0.2 × 0.018 + 0.3 × 0.2 × 0.0015) = 0.0100 + 0.0009 + 0.0009 + 2 × (0.0006 + 0.0018 + 0.00009) = 0.01678.
  3. Portfolio volatility = √0.01678 = 12.95 %, against a weighted average of volatilities of 16.0 %; the difference is the diversification benefit.
  4. Risk contributions: asset 1 carries 73.9 % of the variance with 50 % of the capital; asset 2 carries 9.5 % with 30 %; asset 3 carries 16.6 % with 20 %.

Part 2: drift versus rebalancing. €10,000 split 60 / 40 between asset A and asset B. Annual returns: A +20 %, −10 %, +15 %; B +2 %, +4 %, +1 %.

  1. Drift: A: 6,000 × 1.20 × 0.90 × 1.15 = €7,452; B: 4,000 × 1.02 × 1.04 × 1.01 = €4,285.63. Total €11,737.63; A's weight has drifted to 63.5 %.
  2. Yearly rebalancing: year 1 total = 7,200 + 4,080 = €11,280 → reset to 6,768 / 4,512; year 2 = 6,091.20 + 4,692.48 = €10,783.68 → reset; year 3 = 7,440.74 + 4,356.61 = €11,797.35.
  3. Rebalancing ended €59.72 higher because it sold A after +20 % and bought back after −10 %. With a steadily trending A, drift would have won; rebalancing controls risk, it does not guarantee return.

Common mistake

Using the full-sample covariance matrix to size a portfolio that starts at the beginning of the sampleA backtest that computes rets.cov() over 2015–2025 and uses the result to set weights in 2015 is using information from the future. Estimate the covariance on a rolling or expanding window (rets.rolling(252).cov() or rets.loc[:date].cov()) and apply it only to the following period. Correlations also rise in crises, so a calm-period matrix understates the risk of the moment when it matters.

Practice activity

On paper: two assets with annualised volatilities 18 % and 6 %, correlation 0.3, weights 70 / 30 %. (a) Build the 2 × 2 covariance matrix. (b) Compute portfolio variance and volatility. (c) Compute each asset's risk contribution as a share of variance. (d) Recompute the volatility with the correlation at 0.9 and state in one sentence why this matters.

Show solution

(a) Σ₁₁ = 0.0324; Σ₂₂ = 0.0036; Σ₁₂ = 0.3 × 0.18 × 0.06 = 0.00324.

(b) Variance = 0.49 × 0.0324 + 0.09 × 0.0036 + 2 × 0.7 × 0.3 × 0.00324 = 0.015876 + 0.000324 + 0.0013608 = 0.0175608; volatility = 13.25 %.

(c) Marginal: asset 1: 0.7 × 0.0324 + 0.3 × 0.00324 = 0.023652; asset 2: 0.7 × 0.00324 + 0.3 × 0.0036 = 0.003348. Contributions: 0.7 × 0.023652 / 0.0175608 = 94.3 %; 0.3 × 0.003348 / 0.0175608 = 5.7 %.

(d) With ρ = 0.9: Σ₁₂ = 0.00972; variance = 0.015876 + 0.000324 + 0.0040824 = 0.0202824; volatility = 14.24 %. The "diversifier" adds little when correlation rises, which is exactly what tends to happen in a sell-off; a portfolio should be judged on stressed correlations, not only on the historical average.

Limitations: sample covariance from a few years of daily data is noisy; small changes in the window can move correlations by 0.1 or more.

Key takeaways

  • Align dates and currencies before computing returns; decide explicitly how to treat missing days.
  • Annualise covariance by multiplying by the periods per year; correlation needs no scaling.
  • Portfolio volatility is √(wᵀ Σ w); risk contributions w × (Σw) / variance show where the risk really sits.
  • Simulate rebalancing by resampling returns and resetting holdings; estimate covariance only from data available at the time.

Next step

Lesson 12 automates the daily routine: a scheduled script that fetches data, computes signals and writes a report, with API keys and rate limits handled properly.

Self-check

Answer the questions, then check your answers. You can retry as often as you like.

1. Two assets have annualised volatilities of 20 % and 10 %, correlation 0.0, and weights 50 / 50 %. What is the portfolio volatility?

2. A daily returns DataFrame has a covariance matrix with 0.0001 on the diagonal for asset X. What is X's annualised volatility (252 days)?

3. In a rebalancing simulation the line hold = hold.sum() * target is executed before hold = hold * (1 + r.values) in each loop iteration. What is the effect?

Automatic self-check for your own revision. Your answers are checked in your browser and are not seen or assessed by us. It is not an exam and not a qualification.

Your progress

“Complete” only means you marked the lesson as done. It is not a test of your understanding. Progress and notes are saved only in this browser on this device — not in an account.