mirror of
https://github.com/wshobson/agents.git
synced 2026-03-18 09:37:15 +00:00
Add domain expert agents with comprehensive skill sets: - service-mesh-expert (cloud-infrastructure): Istio/Linkerd patterns, mTLS, observability - event-sourcing-architect (backend-development): CQRS, event stores, projections, sagas - vector-database-engineer (llm-application-dev): embeddings, similarity search, hybrid search - monorepo-architect (developer-essentials): Nx, Turborepo, Bazel, pnpm workspaces - threat-modeling-expert (security-scanning): STRIDE, attack trees, security requirements Update all documentation to reflect correct counts: - 67 plugins, 99 agents, 107 skills, 71 commands
556 lines
18 KiB
Markdown
556 lines
18 KiB
Markdown
---
|
|
name: risk-metrics-calculation
|
|
description: 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.
|
|
---
|
|
|
|
# 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/)
|