risk-metrics-calculation

Par wshobson · agents

Calculez les métriques de risque d'un portefeuille, notamment la VaR, la CVaR, le ratio de Sharpe, le ratio de Sortino et l'analyse des drawdowns. À utiliser pour mesurer le risque d'un portefeuille, mettre en place des limites de risque ou développer des systèmes de surveillance du risque.

npx skills add https://github.com/wshobson/agents --skill risk-metrics-calculation

Calcul des métriques de risque

Toolkit complet de mesure du risque pour la gestion de portefeuille, incluant la Value at Risk, l'Expected Shortfall et l'analyse des drawdowns.

Quand utiliser cette skill

  • Mesurer le risque de portefeuille
  • Implémenter des limites de risque
  • Construire des tableaux de bord de risque
  • Calculer les rendements ajustés au risque
  • Définir les tailles de position
  • Reporting réglementaire

Concepts fondamentaux

1. Catégories de métriques de risque

Catégorie Métriques Cas d'usage
Volatilité Écart type, Bêta Risque général
Risque extrême VaR, CVaR Pertes extrêmes
Drawdown Max DD, Calmar Préservation capital
Ajusté risque Sharpe, Sortino Performance

2. Horizons temporels

Intraday:   VaR minute/horaire pour traders de jour
Daily:      Reporting de risque standard
Weekly:     Décisions de rééquilibrage
Monthly:    Attribution de performance
Annual:     Allocation stratégique

Implémentation

Pattern 1 : Métriques de risque fondamentales

import numpy as np
import pandas as pd
from scipy import stats
from typing import Dict, Optional, Tuple

class RiskMetrics:
    """Calculs de métriques de risque fondamentales."""

    def __init__(self, returns: pd.Series, rf_rate: float = 0.02):
        """
        Args:
            returns: Série de rendements périodiques
            rf_rate: Taux sans risque annuel
        """
        self.returns = returns
        self.rf_rate = rf_rate
        self.ann_factor = 252  # Jours de trading par an

    # Métriques de volatilité
    def volatility(self, annualized: bool = True) -> float:
        """Écart type des rendements."""
        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:
        """Écart type des rendements sous le seuil."""
        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:
        """Bêta relatif au marché."""
        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:
        """VaR historique au niveau de confiance."""
        return -np.percentile(self.returns, (1 - confidence) * 100)

    def var_parametric(self, confidence: float = 0.95) -> float:
        """VaR paramétrique en supposant une distribution normale."""
        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 avec expansion de Cornish-Fisher pour la non-normalité."""
        z = stats.norm.ppf(confidence)
        s = stats.skew(self.returns)  # Asymétrie
        k = stats.kurtosis(self.returns)  # Kurtosis excédentaire

        # Expansion de Cornish-Fisher
        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())

    # VaR conditionnel (Expected Shortfall)
    def cvar(self, confidence: float = 0.95) -> float:
        """Expected Shortfall / CVaR / VaR moyen."""
        var = self.var_historical(confidence)
        return -self.returns[self.returns <= -var].mean()

    # Analyse des drawdowns
    def drawdowns(self) -> pd.Series:
        """Calcule la série des drawdowns."""
        cumulative = (1 + self.returns).cumprod()
        running_max = cumulative.cummax()
        return (cumulative - running_max) / running_max

    def max_drawdown(self) -> float:
        """Drawdown maximum."""
        return self.drawdowns().min()

    def avg_drawdown(self) -> float:
        """Drawdown moyen."""
        dd = self.drawdowns()
        return dd[dd < 0].mean() if (dd < 0).any() else 0

    def drawdown_duration(self) -> Dict[str, int]:
        """Statistiques sur la durée des drawdowns."""
        dd = self.drawdowns()
        in_drawdown = dd < 0

        # Trouver les périodes de drawdown
        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
        }

    # Rendements ajustés au risque
    def sharpe_ratio(self) -> float:
        """Ratio de Sharpe annualisé."""
        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:
        """Ratio de Sortino utilisant l'écart type à la baisse."""
        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:
        """Ratio de Calmar (rendement / drawdown maximum)."""
        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:
        """Ratio Omega."""
        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()

    # Ratio d'information
    def information_ratio(self, benchmark_returns: pd.Series) -> float:
        """Ratio d'information 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

    # Résumé
    def summary(self) -> Dict[str, float]:
        """Génère un résumé complet des risques."""
        dd_stats = self.drawdown_duration()

        return {
            # Rendements
            "total_return": (1 + self.returns).prod() - 1,
            "annual_return": (1 + self.returns).prod() ** (self.ann_factor / len(self.returns)) - 1,

            # Volatilité
            "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"],

            # Ajustés au risque
            "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 : Risque de portefeuille

class PortfolioRisk:
    """Calculs de risque au niveau du portefeuille."""

    def __init__(
        self,
        returns: pd.DataFrame,
        weights: Optional[pd.Series] = None
    ):
        """
        Args:
            returns: DataFrame avec rendements des actifs (colonnes = actifs)
            weights: Poids du portefeuille (défaut : poids égaux)
        """
        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:
        """Rendement du portefeuille pondéré."""
        return (self.returns @ self.weights).mean() * self.ann_factor

    def portfolio_volatility(self) -> float:
        """Volatilité du portefeuille."""
        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:
        """Contribution marginale au risque par actif."""
        cov_matrix = self.returns.cov() * self.ann_factor
        port_vol = self.portfolio_volatility()

        # Contribution marginale
        mrc = (cov_matrix @ self.weights) / port_vol
        return mrc

    def component_risk(self) -> pd.Series:
        """Contribution en composante au risque total."""
        mrc = self.marginal_risk_contribution()
        return self.weights * mrc

    def risk_parity_weights(self, target_vol: float = None) -> pd.Series:
        """Calcule les poids en parité de risque."""
        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  # Contribution égale au risque
            return np.sum((rc - target_rc) ** 2)

        constraints = [
            {"type": "eq", "fun": lambda w: np.sum(w) - 1},  # Poids somme à 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:
        """Matrice de corrélation des actifs."""
        return self.returns.corr()

    def diversification_ratio(self) -> float:
        """Ratio de diversification (plus élevé = plus diversifié)."""
        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:
        """Erreur de suivi 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:
        """Corrélation pendant les périodes de stress."""
        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 : Métriques de risque glissantes

class RollingRiskMetrics:
    """Calculs de risque sur fenêtre glissante."""

    def __init__(self, returns: pd.Series, window: int = 63):
        """
        Args:
            returns: Série de rendements
            window: Taille de la fenêtre glissante (défaut : 63 = ~3 mois)
        """
        self.returns = returns
        self.window = window

    def rolling_volatility(self, annualized: bool = True) -> pd.Series:
        """Volatilité glissante."""
        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:
        """Ratio de Sharpe glissant."""
        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:
        """VaR historique glissante."""
        return self.returns.rolling(self.window).apply(
            lambda x: -np.percentile(x, (1 - confidence) * 100),
            raw=True
        )

    def rolling_max_drawdown(self) -> pd.Series:
        """Drawdown maximum glissant."""
        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:
        """Bêta glissant vs marché."""
        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:
        """Classe le régime de volatilité."""
        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 : Tests de stress

class StressTester:
    """Tests de stress historiques et hypothétiques."""

    # Périodes de crise historiques
    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]:
        """Teste le portefeuille contre une période de crise historique."""
        if scenario_name not in self.HISTORICAL_SCENARIOS:
            raise ValueError(f"Scénario inconnu : {scenario_name}")

        start, end = self.HISTORICAL_SCENARIOS[scenario_name]

        # Obtient les rendements pendant la crise
        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:
        """
        Teste le portefeuille contre des chocs hypothétiques.

        Args:
            shocks: Dict de {actif: rendement_choc}
        """
        if self.weights is None:
            raise ValueError("Poids requis pour le test de stress hypothétique")

        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]:
        """Test de stress Monte Carlo avec volatilité élevée."""
        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()

Référence rapide

# Usage quotidien
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%}")

# Résumé complet
summary = metrics.summary()
for metric, value in summary.items():
    print(f"{metric}: {value:.4f}")

Bonnes pratiques

À faire

  • Utiliser plusieurs métriques - Aucune métrique seule ne capture tous les risques
  • Considérer le risque extrême - La VaR seule ne suffit pas, utiliser la CVaR
  • Analyse glissante - Le risque change dans le temps
  • Tests de stress - Historiques et hypothétiques
  • Documenter les hypothèses - Distribution, période rétrospective, etc.

À éviter

  • Ne pas se fier à la VaR seule - Sous-estime le risque extrême
  • Ne pas supposer la normalité - Les rendements ont des queues épaisses
  • Ne pas ignorer la corrélation - Elle augmente en stress
  • Ne pas utiliser de périodes rétrospectives courtes - Rater les changements de régime
  • Ne pas oublier les coûts de transaction - Affectent le risque réalisé

Skills similaires