30 — Capstone: Full Portfolio Risk and Attribution Review¶
Learning objectives¶
Apply concepts from across the whole curriculum to one realistic
portfolio: absolute and active risk decomposition, fixed-income
duration/DV01 at the bond level, a rates and a credit scenario, and
Brinson attribution — then write the one-page summary a PM would
actually read. This notebook has less hand-holding than earlier ones on
purpose: by this point you should be composing src/pm functions
yourself, not being walked through each one.
Data¶
data/mock_portfolio.csv, data/mock_benchmark.csv, and
data/mock_bonds.csv — real files in this repo that no other notebook
uses. Asset-class weights are assumed to sit on a flat $100M total AUM
(a clean round number, not implied by the CSVs). `mock_bonds.csv`'s
three bonds are a separate, more granular zoom into the fixed-income
sleeve — their $21M total doesn't need to reconcile exactly against the
25%+15%+10% (UST+IG+HY) asset-class weights above; real bond books hold
far more than three lines per bucket. Price history isn't provided at
useful length, so the return series below is synthetic (seeded,
reproducible) with hand-specified, realistic vols and correlations —
stated plainly here, not hidden.
Free learning pack¶
This notebook composes concepts you've already studied. If any of these
are unfamiliar, go back to them first rather than muddling through here:
reference/concepts/risk_contribution.md,
reference/concepts/mcte_and_group_risk.md,
reference/concepts/information_ratio.md,
reference/fixed_income/dv01.md,
reference/concepts/brinson_attribution.md.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
portfolio = pd.read_csv("../../data/mock_portfolio.csv").set_index("asset")["weight"]
benchmark = pd.read_csv("../../data/mock_benchmark.csv").set_index("asset")["weight"]
bonds = pd.read_csv("../../data/mock_bonds.csv")
assets = list(portfolio.index)
total_aum = 100_000_000.0
portfolio_w = portfolio.values
benchmark_w = benchmark.reindex(assets).values
dollar_values = portfolio * total_aum
print(dollar_values)
Setup — synthetic return history¶
Given (not a MANUAL FIRST step — this is infrastructure, not a concept under test): a seeded multivariate-normal daily return series for the 6 asset classes, built from hand-specified annualized vols and a correlation matrix. 252 trading days (~1 year).
annual_vols = np.array([0.16, 0.18, 0.05, 0.06, 0.10, 0.20]) # US_EQ, INTL_EQ, UST, IG, HY, CMDTY
corr = np.array([
[1.00, 0.85, -0.20, 0.10, 0.55, 0.25],
[0.85, 1.00, -0.15, 0.10, 0.50, 0.30],
[-0.20, -0.15, 1.00, 0.60, -0.10, -0.05],
[0.10, 0.10, 0.60, 1.00, 0.65, 0.05],
[0.55, 0.50, -0.10, 0.65, 1.00, 0.15],
[0.25, 0.30, -0.05, 0.05, 0.15, 1.00],
])
annual_cov = np.outer(annual_vols, annual_vols) * corr
daily_mean = np.array([0.08, 0.07, 0.03, 0.04, 0.06, 0.03]) / 252
daily_cov = annual_cov / 252
rng = np.random.default_rng(42)
n_days = 252
daily_returns = rng.multivariate_normal(daily_mean, daily_cov, size=n_days)
returns_df = pd.DataFrame(daily_returns, columns=assets)
sample_cov_annual = returns_df.cov().values * 252
print("sample annualized vols:", np.round(np.sqrt(np.diag(sample_cov_annual)), 4))
Section A — Absolute risk¶
MANUAL FIRST: using pm.risk.portfolio_volatility and
pm.risk.group_risk_contribution, compute the portfolio's total
annualized volatility and its risk decomposition by group
(groups = ["Equity","Equity","Rates","Credit","Credit","Commodities"],
matching assets order).
from pm.risk import group_risk_contribution, portfolio_volatility
groups = ["Equity", "Equity", "Rates", "Credit", "Credit", "Commodities"]
# MANUAL FIRST:
port_vol = None
group_contributions = None
print(port_vol)
print(group_contributions)
# CHECK (uncomment after your attempt):
# assert np.isclose(port_vol, 0.0880, atol=1e-3)
# assert np.isclose(sum(group_contributions.values()), port_vol, atol=1e-6)
PREDICT¶
Before running the next cell: with US_EQ + INTL_EQ at 45% combined capital weight but the highest volatility and the highest correlation to everything else, roughly what share of total portfolio risk do you expect "Equity" to be — close to 45%, meaningfully more, or meaningfully less?
Section B — Active risk¶
MANUAL FIRST: compute active weights, ex-ante tracking error, and
component contribution to tracking error (CCTE) using
pm.active.active_weights, tracking_error, and
component_contribution_to_tracking_error.
from pm.active import (
active_weights,
component_contribution_to_tracking_error,
information_ratio,
realized_tracking_error,
tracking_error,
)
# MANUAL FIRST:
active = None
ex_ante_te = None
ccte = None
print(dict(zip(assets, active)))
print("ex-ante TE:", ex_ante_te)
print(dict(zip(assets, ccte)))
# CHECK (uncomment after your attempt):
# assert np.allclose(active, [0.05, -0.05, 0, 0, 0, 0], atol=1e-9)
# assert np.isclose(ex_ante_te, 0.00489, atol=1e-4)
# assert np.isclose(ccte.sum(), ex_ante_te, atol=1e-6)
Ex-ante vs. ex-post — did the risk model match reality?¶
MANUAL FIRST: compute the realized tracking error and information
ratio from the actual return series (portfolio_w @ returns_df.values.T
gives the portfolio's daily return series; same for the benchmark), then
compare realized TE against the ex-ante figure above.
# MANUAL FIRST:
portfolio_returns = None
benchmark_returns = None
realized_te = None
ir = None
print("realized TE:", realized_te, " vs ex-ante:", ex_ante_te)
print("information ratio:", ir)
# CHECK (uncomment after your attempt):
# assert np.isclose(realized_te, ex_ante_te, atol=1e-6), (
# "these should nearly match here - the synthetic data was generated "
# "from the exact covariance the ex-ante figure used. In real life "
# "they diverge, which is the whole point of checking."
# )
Section C — Fixed-income detail (bond level)¶
MANUAL FIRST: for each bond in bonds, compute its DV01
(pm.fixed_income.duration.dv01, per 100 face) and scale by
market_value / face to get the position's dollar DV01. Sum for the
sleeve's total DV01.
from pm.fixed_income.duration import dv01
# MANUAL FIRST:
# build a Series/array of each bond's dollar DV01, then sum it.
bond_dv01s = None
total_dv01 = None
print(bond_dv01s)
print("total FI sleeve DV01: $", total_dv01, "/bp")
# CHECK (uncomment after your attempt):
# assert np.isclose(total_dv01, 10058, atol=5)
Section D — Scenarios¶
MANUAL FIRST: apply two shocks to the fixed-income sleeve using its own DV01/spread-duration figures:
- A parallel rates rally: yields fall 50bp.
pnl = -shock_bp * total_dv01. - A credit selloff: IG spreads +75bp, HY spreads +200bp, via
pm.fixed_income.credit.spread_pnlon each bond's own market value and spread duration.
Then combine both into one risk-off scenario P&L.
from pm.fixed_income.credit import spread_pnl
rate_shock_bp = -50
ig_shock_bp = 75
hy_shock_bp = 200
# MANUAL FIRST:
rate_scenario_pnl = None
ig_pnl = None
hy_pnl = None
combined_risk_off_pnl = None
print("rates:", rate_scenario_pnl, " IG:", ig_pnl, " HY:", hy_pnl)
print("combined risk-off scenario P&L: $", combined_risk_off_pnl)
# CHECK (uncomment after your attempt):
# assert np.isclose(rate_scenario_pnl, 502895, atol=10)
# assert np.isclose(ig_pnl, -288000, atol=1)
# assert np.isclose(hy_pnl, -192000, atol=1)
PREDICT¶
Rates rallying and credit spreads widening are the classic "risk-off" combination. Given the numbers you just computed, does this particular book's rates gain fully offset its credit loss, partially offset it, or get swamped by it?
Section E — Attribution¶
MANUAL FIRST: using each asset's realized cumulative return over the
period (pm.returns.cumulative_return on each column of returns_df)
as both rp and rb in pm.attribution.brinson_attribution — since
the portfolio and benchmark hold the same underlying assets, just at
different weights, this isolates pure allocation effect with zero
selection or interaction by construction.
from pm.attribution import brinson_attribution
from pm.returns import cumulative_return
# MANUAL FIRST:
asset_returns = None # array of cumulative_return(...) per asset, same order as `assets`
allocation, selection, interaction = None, None, None
print(dict(zip(assets, allocation)))
active_return = float(portfolio_w @ asset_returns - benchmark_w @ asset_returns)
print("active return:", active_return, " sum of effects:", allocation.sum() + selection.sum() + interaction.sum())
# CHECK (uncomment after your attempt):
# assert np.allclose(selection, 0.0, atol=1e-9) and np.allclose(interaction, 0.0, atol=1e-9)
# assert np.isclose(allocation.sum() + selection.sum() + interaction.sum(), active_return, atol=1e-9)
Synthesize — the one-page PM summary¶
Fill this in yourself using the numbers you computed above — don't just restate the numbers, interpret them the way you'd actually brief a PM.
- What's driving risk? (which group, and roughly what share)
- How much risk is active vs. total? (compare ex-ante TE to total vol)
- What's the portfolio's DV01? (the FI sleeve figure from Section C)
- What happens in a risk-off scenario? (does the rates rally help, and by how much relative to the credit loss)
- Where did relative performance come from this period? (allocation vs. selection, and which specific bet)
- Did the risk model check out? (ex-ante vs. realized TE)
Reference¶
reference/concepts/mcte_and_group_risk.md
reference/concepts/information_ratio.md
reference/concepts/downside_risk.md
reference/fixed_income/dv01.md
reference/concepts/brinson_attribution.md
reference/concepts/stress_testing.md
Test¶
There's no dedicated test file for this notebook — it composes tested
functions from tests/test_risk.py, tests/test_active.py,
tests/test_fixed_income.py, and tests/test_attribution.py. Run all
four if you want to confirm the underlying functions themselves are
correct: pytest tests/test_risk.py tests/test_active.py tests/test_fixed_income.py tests/test_attribution.py
ORAL CHECK¶
Deliver the one-page summary above out loud, in under two minutes, to someone who has never seen this portfolio — as if they're the PM asking "what do I need to know before the market opens." If any of your six bullets needs a follow-up question to make sense, that's the part to tighten.
This is the last notebook in the curriculum. If everything above made
sense without re-reading old reference pages, you've covered the
material /pmexpert status set out to track.