risk-metrics-calculation

Portfolio risk measurement with VaR, CVaR, Sharpe, Sortino, and drawdown analysis. Covers 15+ risk metrics across volatility, tail risk, drawdown, and risk-adjusted return categories with parametric, historical, and Cornish-Fisher VaR methods Includes rolling window analysis, portfolio-level calculations with marginal risk contribution and risk parity optimization, and stress testing against historical crises or hypothetical shocks Supports Monte Carlo simulation with elevated volatility, correlation analysis during stress periods, and regime classification for dynamic risk monitoring Provides complete drawdown tracking with duration statistics, beta calculation, and information ratio for benchmark comparison

INSTALLATION
npx skills add https://github.com/wshobson/agents --skill risk-metrics-calculation
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

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

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 &#x26; ~in_drawdown.shift(1).fillna(False)

        drawdown_ends = ~in_drawdown &#x26; 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 &#x26; 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

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

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

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

# 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
BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card