risk-metrics-calculation
upstream

Calculate portfolio risk metrics including VaR, CVaR, Sharpe, Sortino, and drawdown analysis. Use when measuring portfolio risk, implementing risk limits, or building risk monitoring systems.

ID: risk-metrics-calculation
Preview
Codigo

Risk Metrics Calculation


Comprehensive risk measurement toolkit for portfolio management, including Value at Risk, Expected Shortfall, and drawdown analysis.


When to Use This Skill


  • Measuring portfolio risk
  • Implementing risk limits
  • Building risk dashboards
  • Calculating risk-adjusted returns
  • Setting position sizes
  • Regulatory reporting

Core Concepts


1. Risk Metric Categories


| Category | Metrics | Use Case |

|----------|---------|----------|

| Volatility | Std Dev, Beta | General risk |

| Tail Risk | VaR, CVaR | Extreme losses |

| Drawdown | Max DD, Calmar | Capital preservation |

| Risk-Adjusted | Sharpe, Sortino | Performance |


2. Time Horizons


``

Intraday: Minute/hourly VaR for day traders

Daily: Standard risk reporting

Weekly: Rebalancing decisions

Monthly: Performance attribution

Annual: Strategic allocation

`


Implementation


Pattern 1: Core Risk Metrics


`python

import numpy as np

import pandas as pd

from scipy import stats

from typing import Dict, Optional, Tuple


class RiskMetrics:

"""Core risk metric calculations."""


def __init__(self, returns: pd.Series, rf_rate: float = 0.02):

"""

Args:

returns: Series of periodic returns

rf_rate: Annual risk-free rate

"""

self.returns = returns

self.rf_rate = rf_rate

self.ann_factor = 252 # Trading days per year


# Volatility Metrics

def volatility(self, annualized: bool = True) -> float:

"""Standard deviation of returns."""

vol = self.returns.std()

if annualized:

vol *= np.sqrt(self.ann_factor)

return vol


def downside_deviation(self, threshold: float = 0, annualized: bool = True) -> float:

"""Standard deviation of returns below threshold."""

downside = self.returns[self.returns < threshold]

if len(downside) == 0:

return 0.0

dd = downside.std()

if annualized:

dd *= np.sqrt(self.ann_factor)

return dd


def beta(self, market_returns: pd.Series) -> float:

"""Beta relative to market."""

aligned = pd.concat([self.returns, market_returns], axis=1).dropna()

if len(aligned) < 2:

return np.nan

cov = np.cov(aligned.iloc[:, 0], aligned.iloc[:, 1])

return cov[0, 1] / cov[1, 1] if cov[1, 1] != 0 else 0


# Value at Risk

def var_historical(self, confidence: float = 0.95) -> float:

"""Historical VaR at confidence level."""

return -np.percentile(self.returns, (1 - confidence) * 100)


def var_parametric(self, confidence: float = 0.95) -> float:

"""Parametric VaR assuming normal distribution."""

z_score = stats.norm.ppf(confidence)

return self.returns.mean() - z_score * self.returns.std()


def var_cornish_fisher(self, confidence: float = 0.95) -> float:

"""VaR with Cornish-Fisher expansion for non-normality."""

z = stats.norm.ppf(confidence)

s = stats.skew(self.returns) # Skewness

k = stats.kurtosis(self.returns) # Excess kurtosis


# Cornish-Fisher expansion

z_cf = (z + (z**2 - 1) * s / 6 +

(z**3 - 3*z) * k / 24 -

(2*z**3 - 5*z) * s**2 / 36)


return -(self.returns.mean() + z_cf * self.returns.std())


# Conditional VaR (Expected Shortfall)

def cvar(self, confidence: float = 0.95) -> float:

"""Expected Shortfall / CVaR / Average VaR."""

var = self.var_historical(confidence)

return -self.returns[self.returns <= -var].mean()


# Drawdown Analysis

def drawdowns(self) -> pd.Series:

"""Calculate drawdown series."""

cumulative = (1 + self.returns).cumprod()

running_max = cumulative.cummax()

return (cumulative - running_max) / running_max


def max_drawdown(self) -> float:

"""Maximum drawdown."""

return self.drawdowns().min()


def avg_drawdown(self) -> float:

"""Average drawdown."""

dd = self.drawdowns()

return dd[dd < 0].mean() if (dd < 0).any() else 0


def drawdown_duration(self) -> Dict[str, int]:

"""Drawdown duration statistics."""

dd = self.drawdowns()

in_drawdown = dd < 0


# Find drawdown periods

drawdown_starts = in_drawdown & ~in_drawdown.shift(1).fillna(False)

drawdown_ends = ~in_drawdown & in_drawdown.shift(1).fillna(False)


durations = []

current_duration = 0


for i in range(len(dd)):

if in_drawdown.iloc[i]:

current_duration += 1

elif current_duration > 0:

durations.append(current_duration)

current_duration = 0


if current_duration > 0:

durations.append(current_duration)


return {

"max_duration": max(durations) if durations else 0,

"avg_duration": np.mean(durations) if durations else 0,

"current_duration": current_duration

}


# Risk-Adjusted Returns

def sharpe_ratio(self) -> float:

"""Annualized Sharpe ratio."""

excess_return = self.returns.mean() * self.ann_factor - self.rf_rate

vol = self.volatility(annualized=True)

return excess_return / vol if vol > 0 else 0


def sortino_ratio(self) -> float:

"""Sortino ratio using downside deviation."""

excess_return = self.returns.mean() * self.ann_factor - self.rf_rate

dd = self.downside_deviation(threshold=0, annualized=True)

return excess_return / dd if dd > 0 else 0


def calmar_ratio(self) -> float:

"""Calmar ratio (return / max drawdown)."""

annual_return = (1 + self.returns).prod() ** (self.ann_factor / len(self.returns)) - 1

max_dd = abs(self.max_drawdown())

return annual_return / max_dd if max_dd > 0 else 0


def omega_ratio(self, threshold: float = 0) -> float:

"""Omega ratio."""

returns_above = self.returns[self.returns > threshold] - threshold

returns_below = threshold - self.returns[self.returns <= threshold]


if returns_below.sum() == 0:

return np.inf


return returns_above.sum() / returns_below.sum()


# Information Ratio

def information_ratio(self, benchmark_returns: pd.Series) -> float:

"""Information ratio vs benchmark."""

active_returns = self.returns - benchmark_returns

tracking_error = active_returns.std() * np.sqrt(self.ann_factor)

active_return = active_returns.mean() * self.ann_factor

return active_return / tracking_error if tracking_error > 0 else 0


# Summary

def summary(self) -> Dict[str, float]:

"""Generate comprehensive risk summary."""

dd_stats = self.drawdown_duration()


return {

# Returns

"total_return": (1 + self.returns).prod() - 1,

"annual_return": (1 + self.returns).prod() ** (self.ann_factor / len(self.returns)) - 1,


# Volatility

"annual_volatility": self.volatility(),

"downside_deviation": self.downside_deviation(),


# VaR & CVaR

"var_95_historical": self.var_historical(0.95),

"var_99_historical": self.var_historical(0.99),

"cvar_95": self.cvar(0.95),


# Drawdowns

"max_drawdown": self.max_drawdown(),

"avg_drawdown": self.avg_drawdown(),

"max_drawdown_duration": dd_stats["max_duration"],


# Risk-Adjusted

"sharpe_ratio": self.sharpe_ratio(),

"sortino_ratio": self.sortino_ratio(),

"calmar_ratio": self.calmar_ratio(),

"omega_ratio": self.omega_ratio(),


# Distribution

"skewness": stats.skew(self.returns),

"kurtosis": stats.kurtosis(self.returns),

}

`


Pattern 2: Portfolio Risk


`python

class PortfolioRisk:

"""Portfolio-level risk calculations."""


def __init__(

self,

returns: pd.DataFrame,

weights: Optional[pd.Series] = None

):

"""

Args:

returns: DataFrame with asset returns (columns = assets)

weights: Portfolio weights (default: equal weight)

"""

self.returns = returns

self.weights = weights if weights is not None else \

pd.Series(1/len(returns.columns), index=returns.columns)

self.ann_factor = 252


def portfolio_return(self) -> float:

"""Weighted portfolio return."""

return (self.returns @ self.weights).mean() * self.ann_factor


def portfolio_volatility(self) -> float:

"""Portfolio volatility."""

cov_matrix = self.returns.cov() * self.ann_factor

port_var = self.weights @ cov_matrix @ self.weights

return np.sqrt(port_var)


def marginal_risk_contribution(self) -> pd.Series:

"""Marginal contribution to risk by asset."""

cov_matrix = self.returns.cov() * self.ann_factor

port_vol = self.portfolio_volatility()


# Marginal contribution

mrc = (cov_matrix @ self.weights) / port_vol

return mrc


def component_risk(self) -> pd.Series:

"""Component contribution to total risk."""

mrc = self.marginal_risk_contribution()

return self.weights * mrc


def risk_parity_weights(self, target_vol: float = None) -> pd.Series:

"""Calculate risk parity weights."""

from scipy.optimize import minimize


n = len(self.returns.columns)

cov_matrix = self.returns.cov() * self.ann_factor


def risk_budget_objective(weights):

port_vol = np.sqrt(weights @ cov_matrix @ weights)

mrc = (cov_matrix @ weights) / port_vol

rc = weights * mrc

target_rc = port_vol / n # Equal risk contribution

return np.sum((rc - target_rc) ** 2)


constraints = [

{"type": "eq", "fun": lambda w: np.sum(w) - 1}, # Weights sum to 1

]

bounds = [(0.01, 1.0) for _ in range(n)] # Min 1%, max 100%

x0 = np.array([1/n] * n)


result = minimize(

risk_budget_objective,

x0,

method="SLSQP",

bounds=bounds,

constraints=constraints

)


return pd.Series(result.x, index=self.returns.columns)


def correlation_matrix(self) -> pd.DataFrame:

"""Asset correlation matrix."""

return self.returns.corr()


def diversification_ratio(self) -> float:

"""Diversification ratio (higher = more diversified)."""

asset_vols = self.returns.std() * np.sqrt(self.ann_factor)

weighted_vol = (self.weights * asset_vols).sum()

port_vol = self.portfolio_volatility()

return weighted_vol / port_vol if port_vol > 0 else 1


def tracking_error(self, benchmark_returns: pd.Series) -> float:

"""Tracking error vs benchmark."""

port_returns = self.returns @ self.weights

active_returns = port_returns - benchmark_returns

return active_returns.std() * np.sqrt(self.ann_factor)


def conditional_correlation(

self,

threshold_percentile: float = 10

) -> pd.DataFrame:

"""Correlation during stress periods."""

port_returns = self.returns @ self.weights

threshold = np.percentile(port_returns, threshold_percentile)

stress_mask = port_returns <= threshold

return self.returns[stress_mask].corr()

`


Pattern 3: Rolling Risk Metrics


`python

class RollingRiskMetrics:

"""Rolling window risk calculations."""


def __init__(self, returns: pd.Series, window: int = 63):

"""

Args:

returns: Return series

window: Rolling window size (default: 63 = ~3 months)

"""

self.returns = returns

self.window = window


def rolling_volatility(self, annualized: bool = True) -> pd.Series:

"""Rolling volatility."""

vol = self.returns.rolling(self.window).std()

if annualized:

vol *= np.sqrt(252)

return vol


def rolling_sharpe(self, rf_rate: float = 0.02) -> pd.Series:

"""Rolling Sharpe ratio."""

rolling_return = self.returns.rolling(self.window).mean() * 252

rolling_vol = self.rolling_volatility()

return (rolling_return - rf_rate) / rolling_vol


def rolling_var(self, confidence: float = 0.95) -> pd.Series:

"""Rolling historical VaR."""

return self.returns.rolling(self.window).apply(

lambda x: -np.percentile(x, (1 - confidence) * 100),

raw=True

)


def rolling_max_drawdown(self) -> pd.Series:

"""Rolling maximum drawdown."""

def max_dd(returns):

cumulative = (1 + returns).cumprod()

running_max = cumulative.cummax()

drawdowns = (cumulative - running_max) / running_max

return drawdowns.min()


return self.returns.rolling(self.window).apply(max_dd, raw=False)


def rolling_beta(self, market_returns: pd.Series) -> pd.Series:

"""Rolling beta vs market."""

def calc_beta(window_data):

port_ret = window_data.iloc[:, 0]

mkt_ret = window_data.iloc[:, 1]

cov = np.cov(port_ret, mkt_ret)

return cov[0, 1] / cov[1, 1] if cov[1, 1] != 0 else 0


combined = pd.concat([self.returns, market_returns], axis=1)

return combined.rolling(self.window).apply(

lambda x: calc_beta(x.to_frame()),

raw=False

).iloc[:, 0]


def volatility_regime(

self,

low_threshold: float = 0.10,

high_threshold: float = 0.20

) -> pd.Series:

"""Classify volatility regime."""

vol = self.rolling_volatility()


def classify(v):

if v < low_threshold:

return "low"

elif v > high_threshold:

return "high"

else:

return "normal"


return vol.apply(classify)

`


Pattern 4: Stress Testing


`python

class StressTester:

"""Historical and hypothetical stress testing."""


# Historical crisis periods

HISTORICAL_SCENARIOS = {

"2008_financial_crisis": ("2008-09-01", "2009-03-31"),

"2020_covid_crash": ("2020-02-19", "2020-03-23"),

"2022_rate_hikes": ("2022-01-01", "2022-10-31"),

"dot_com_bust": ("2000-03-01", "2002-10-01"),

"flash_crash_2010": ("2010-05-06", "2010-05-06"),

}


def __init__(self, returns: pd.Series, weights: pd.Series = None):

self.returns = returns

self.weights = weights


def historical_stress_test(

self,

scenario_name: str,

historical_data: pd.DataFrame

) -> Dict[str, float]:

"""Test portfolio against historical crisis period."""

if scenario_name not in self.HISTORICAL_SCENARIOS:

raise ValueError(f"Unknown scenario: {scenario_name}")


start, end = self.HISTORICAL_SCENARIOS[scenario_name]


# Get returns during crisis

crisis_returns = historical_data.loc[start:end]


if self.weights is not None:

port_returns = (crisis_returns @ self.weights)

else:

port_returns = crisis_returns


total_return = (1 + port_returns).prod() - 1

max_dd = self._calculate_max_dd(port_returns)

worst_day = port_returns.min()


return {

"scenario": scenario_name,

"period": f"{start} to {end}",

"total_return": total_return,

"max_drawdown": max_dd,

"worst_day": worst_day,

"volatility": port_returns.std() * np.sqrt(252)

}


def hypothetical_stress_test(

self,

shocks: Dict[str, float]

) -> float:

"""

Test portfolio against hypothetical shocks.


Args:

shocks: Dict of {asset: shock_return}

"""

if self.weights is None:

raise ValueError("Weights required for hypothetical stress test")


total_impact = 0

for asset, shock in shocks.items():

if asset in self.weights.index:

total_impact += self.weights[asset] * shock


return total_impact


def monte_carlo_stress(

self,

n_simulations: int = 10000,

horizon_days: int = 21,

vol_multiplier: float = 2.0

) -> Dict[str, float]:

"""Monte Carlo stress test with elevated volatility."""

mean = self.returns.mean()

vol = self.returns.std() * vol_multiplier


simulations = np.random.normal(

mean,

vol,

(n_simulations, horizon_days)

)


total_returns = (1 + simulations).prod(axis=1) - 1


return {

"expected_loss": -total_returns.mean(),

"var_95": -np.percentile(total_returns, 5),

"var_99": -np.percentile(total_returns, 1),

"worst_case": -total_returns.min(),

"prob_10pct_loss": (total_returns < -0.10).mean()

}


def _calculate_max_dd(self, returns: pd.Series) -> float:

cumulative = (1 + returns).cumprod()

running_max = cumulative.cummax()

drawdowns = (cumulative - running_max) / running_max

return drawdowns.min()

`


Quick Reference


`python

Daily usage

metrics = RiskMetrics(returns)

print(f"Sharpe: {metrics.sharpe_ratio():.2f}")

print(f"Max DD: {metrics.max_drawdown():.2%}")

print(f"VaR 95%: {metrics.var_historical(0.95):.2%}")


Full summary

summary = metrics.summary()

for metric, value in summary.items():

print(f"{metric}: {value:.4f}")

``


Best Practices


Do's

  • Use multiple metrics - No single metric captures all risk
  • Consider tail risk - VaR isn't enough, use CVaR
  • Rolling analysis - Risk changes over time
  • Stress test - Historical and hypothetical
  • Document assumptions - Distribution, lookback, etc.

Don'ts

  • Don't rely on VaR alone - Underestimates tail risk
  • Don't assume normality - Returns are fat-tailed
  • Don't ignore correlation - Increases in stress
  • Don't use short lookbacks - Miss regime changes
  • Don't forget transaction costs - Affects realized risk

Resources


  • [Risk Management and Financial Institutions (John Hull)](https://www.amazon.com/Risk-Management-Financial-Institutions-5th/dp/1119448115)
  • [Quantitative Risk Management (McNeil, Frey, Embrechts)](https://www.amazon.com/Quantitative-Risk-Management-Techniques-Princeton/dp/0691166277)
  • [pyfolio Documentation](https://quantopian.github.io/pyfolio/)

Informacion

Estado
Activo
Origen upstream
Usos 3
Ultimo uso 23/01 16:57
Actualizado 09/01/2026 02:14

Archivo origen

Z:\Repositorios\core\upstream\plugins\quantitative-trading\skills\risk-metrics-calculation\SKILL.md