\[ % MathJax has no bm package; redefine \bm in terms of \boldsymbol, which it supports natively \newcommand{\bm}[1]{\boldsymbol{#1}} % --- Operators ----------------------------------------------------------- % Expectation operator \DeclareMathOperator{\E}{E} % Variance operator \DeclareMathOperator{\Var}{Var} % Covariance operator \DeclareMathOperator{\Cov}{Cov} % Correlation operator \DeclareMathOperator{\Corr}{Corr} % Rank operator (Spearman rank correlation) \DeclareMathOperator{\Rank}{Rank} % Skewness operator \DeclareMathOperator{\Skewness}{Skewness} % Kurtosis operator \DeclareMathOperator{\Kurtosis}{Kurtosis} % Difference/differential operator (upright d per ISO 80000-2) \newcommand{\Diff}{\mathrm{d}} % --- Risk Measures ------------------------------------------------------- % Value at Risk \DeclareMathOperator{\VaR}{VaR} % Expected Shortfall \DeclareMathOperator{\ES}{ES} % Marginal Value at Risk \DeclareMathOperator{\MVaR}{MVaR} % Component Value at Risk \DeclareMathOperator{\CompVaR}{CVaR} % Incremental Value at Risk \DeclareMathOperator{\IVaR}{IVaR} % Component Expected Shortfall \DeclareMathOperator{\CompES}{CES} % Weighted sensitivity for risk factor in FRTB SBM bucket \DeclareMathOperator{\WS}{WS} % Hedge-benefit ratio for FRTB DRC bucket \DeclareMathOperator{\HBR}{HBR} % Stress scenario risk measure for FRTB NMRF capital add-on \DeclareMathOperator{\SES}{SES} % Jump-to-default exposure for obligor (FRTB DRC) \DeclareMathOperator{\JTD}{JTD} % Loss given default for obligor (FRTB DRC) \DeclareMathOperator{\LGD}{LGD} % Default risk weight for obligor (FRTB DRC) \DeclareMathOperator{\RW}{RW} % ES ratio function \newcommand{\ESratio}{\lambda} % Arbitrary risk measure function (coherence axioms) \newcommand{\RiskMeasure}{\varphi} % Asset position (coherence axioms) \newcommand{\Asset}{A} % First named asset in examples \newcommand{\AssetA}{A} % Second named asset in examples \newcommand{\AssetB}{B} % Third named asset in examples \newcommand{\AssetC}{C} % Constant (risk measure axioms) \newcommand{\Constant}{c} % Bucket-level aggregate sensitivity in FRTB SBM \newcommand{\BucketAgg}{S} % Cross-bucket correlation in FRTB SBM \newcommand{\CrossBucketCorr}{\gamma} % Notional amount for an instrument (FRTB DRC and RRAO) \newcommand{\Notional}{\text{Notional}} % --- Distributions ------------------------------------------------------- % Binomial distribution \DeclareMathOperator{\Binomial}{Binomial} % Uniform distribution \DeclareMathOperator{\Uniform}{Uniform} % Normal distribution \newcommand{\NormalDist}{\mathcal{N}} % Student-t CDF \newcommand{\StudentCDF}{t} % Student-t PDF \newcommand{\StudentPDF}{f} % --- Returns ------------------------------------------------------------- % Price \newcommand{\Price}{P} % Simple (arithmetic) return \newcommand{\SimpleReturns}{R} % Log (compound) return \newcommand{\CompoundReturns}{y} % Matrix of compound returns \newcommand{\ReturnMatrix}{\bm{y}} % Dividend payment \newcommand{\dividend}{d} % --- Volatility ---------------------------------------------------------- % Realised variance \DeclareMathOperator{\RealisedVar}{RV} % Realised volatility (square root of realised variance) \DeclareMathOperator{\RealisedVol}{RVol} % Volatility \newcommand{\Vol}{\sigma} % GARCH intercept parameter \newcommand{\GARCHconst}{\omega} % ARCH coefficient (weight on past squared returns) \newcommand{\ARCHcoeff}{\alpha} % GARCH coefficient (weight on past variance) \newcommand{\GARCHcoeff}{\beta} % EWMA decay factor (typically 0.94) \newcommand{\EWMAdecay}{\lambda} % Standardized residual / error term \newcommand{\StdNormal}{\epsilon} % Leverage parameter in apARCH \newcommand{\APARCHleverage}{\zeta} % Power parameter in apARCH \newcommand{\APARCHpower}{\delta} % Degrees of freedom (Student-t) \newcommand{\DOF}{\nu} % GJR-GARCH leverage parameter \newcommand{\GJRleverage}{\gamma} % GARCH-X external regressor coefficient \newcommand{\RegressorCoeff}{\xi} % Mean (first moment) \newcommand{\Mean}{\mu} % Lag order in volatility models \newcommand{\Lag}{L} % Dummy variable (indicator in regressions) \newcommand{\DummyVar}{D} % --- Portfolio ----------------------------------------------------------- % Portfolio weight (scalar) \newcommand{\weight}{w} % Portfolio weight vector \newcommand{\weights}{\bm{w}} % Covariance matrix \newcommand{\CovMatrix}{\bm{\Sigma}} % Correlation matrix \newcommand{\CorrMatrix}{\bm{C}} % Correlation coefficient \newcommand{\correlation}{\rho} % Number of assets in portfolio \newcommand{\NumberAssets}{K} % Portfolio value \newcommand{\PortfolioValue}{\vartheta} % Asset beta (CAPM) \newcommand{\AssetBeta}{\beta} % Ledoit-Wolf shrinkage intensity \newcommand{\ShrinkageIntensity}{\delta} % Sample covariance matrix \newcommand{\SampleCov}{\bm{S}} % --- Time Series --------------------------------------------------------- % Sample size \newcommand{\SampleSize}{T} % Generic count (number of lags, payments, etc.) \newcommand{\Count}{N} % Estimation window length \newcommand{\EstWindow}{W_E} % Testing window length \newcommand{\TestWindow}{W_T} % Stress window length \newcommand{\StressWindow}{W_S} % AR coefficient \newcommand{\ARcoeff}{\phi} % MA coefficient \newcommand{\MAcoeff}{\psi} % --- Probability --------------------------------------------------------- % Probability level (e.g. 0.01 for 1% VaR) \newcommand{\probability}{p} % Cumulative sorted scenario weight \newcommand{\CumScenarioWeight}{\Omega} % p-quantile of the P and L distribution \newcommand{\Quantile}{q} % Quantile function \newcommand{\QuantileFunction}{Q} % Probability density function \newcommand{\PDF}{f} % Cumulative distribution function \newcommand{\CDF}{F} % Standard normal CDF \newcommand{\NormalCDF}{\Phi} % Inverse standard normal (quantile function) \newcommand{\NormalQuantile}{\Phi^{-1}} % Standard normal density function \newcommand{\NormalPDF}{\phi} % Likelihood function \newcommand{\lik}{\mathcal{L}} % Log-likelihood \newcommand{\LogLikelihood}{\ell} % Parameter vector (MLE estimation) \newcommand{\ParamSet}{\theta} % Parameter space \newcommand{\ParamSpace}{\Theta} % Fisher information matrix \newcommand{\FisherInfo}{\mathcal{I}} % Statistical power (Type II error rate) \newcommand{\StatPower}{\beta} % Significance level (CI coverage complement) \newcommand{\SignifLevel}{\gamma} % --- Options ------------------------------------------------------------- % Call option label \newcommand{\CallOption}{\text{call}} % Put option label \newcommand{\PutOption}{\text{put}} % Strike price \newcommand{\Strike}{X} % Risk-free interest rate \newcommand{\RiskFree}{r_f} % Option maturity (time to expiration) \newcommand{\OptionMaturity}{\tau} % Option delta \newcommand{\OptionDelta}{\Delta} % Option gamma \newcommand{\OptionGamma}{\Gamma} % Option vega \newcommand{\Vega}{\mathcal{V}} % Holding period \newcommand{\HoldingPeriod}{H} % Simulation profit/loss (signed; negative is a loss) \newcommand{\ProfitLoss}{\Pi} % Futures price \newcommand{\Futures}{F} % Hedge ratio \newcommand{\HedgeRatio}{h} % Units of basic asset held \newcommand{\StockHolding}{x^b} % Units of options held \newcommand{\OptionHolding}{x^o} % --- Interest Rates ------------------------------------------------------ % Bond convexity \newcommand{\Convexity}{C} % Interest rate / yield \newcommand{\Yields}{r} % Key rate change, basis points (scalar tenor component); used as \KeyRateChange_k or \KeyRateChange_{t,k}. EXCEPTION to the no-macro-to-macro-indirection rule, decided 2026-08-09: composed from \Yields on purpose, so a future change to the rate glyph propagates here without a follow-up edit. \Yields is defined earlier in generated output (same interest_rates category, TOML order), so expansion order is safe; verify this still holds if either entry is reordered. \newcommand{\KeyRateChange}{\Delta \Yields^{\mathrm{bp}}} % Key rate changes, basis points (vector); used bare or as \KeyRateChanges_t. EXCEPTION to the no-macro-to-macro-indirection rule, decided 2026-08-09: composed from \Yields on purpose, so a future change to the rate glyph propagates here without a follow-up edit. \bm{\Yields} depends on \bm/\boldsymbol correctly expanding a macro argument rather than a bare letter --- confirm this renders correctly the first time either consumer compiles it. \newcommand{\KeyRateChanges}{\Delta \bm{\Yields}^{\mathrm{bp}}} % Dollar value of a basis point \newcommand{\DV}{\text{DV01}} % Mean reversion speed (interest rate models) \newcommand{\MeanReversion}{\varkappa} % Modified duration \newcommand{\ModDur}{D} % Macaulay duration \newcommand{\MacDur}{D_{\text{Mac}}} % Effective duration \newcommand{\EffDur}{D_{\text{eff}}} % DV01 vector \newcommand{\DVvec}{\bm{d}} % Long-run mean rate (Vasicek/CIR/Hull-White) \newcommand{\LongRunRate}{\theta} % Hull-White time-varying drift \newcommand{\HullWhiteDrift}{\varphi} % Cash flow (coupon/principal payment) \newcommand{\CashFlow}{c} % --- Extreme Value ------------------------------------------------------- % Tail index (EVT, Pareto-type tails) \newcommand{\TailIndex}{\iota} % Shape parameter (xi = 1/iota) \newcommand{\ShapeParam}{\varsigma} % Threshold value (EVT) \newcommand{\Threshold}{u} % Extremal index (Leadbetter 1983) \newcommand{\ExtremalIndex}{\theta} % Multivariate extremal coefficient, theta in [1,d]. theta=1 complete tail dependence; theta=d asymptotic independence. Glyph collides with ExtremalIndex and ParamSet; disambiguated at source by macro name. \newcommand{\ExtremalCoef}{\theta} % GEV distribution function \newcommand{\GEV}{\mathcal{H}} % GPD distribution function \newcommand{\GPD}{\mathcal{G}} % GPD scale parameter \newcommand{\GPDscale}{\beta} % GPD scale parameter at a threshold u, distinct from the generic GPD scale \newcommand{\GPDscaleAtThreshold}{\beta_u} % Number of threshold exceedances (EVT) \newcommand{\TailCount}{C} % Sample maximum (block maxima, EVT) \newcommand{\SampleMax}{M} % Kurtosis value (realized, e.g. sample kurtosis) \newcommand{\KurtosisVal}{\kappa} % Pareto scaling constant (EVT) \newcommand{\ParetoConst}{\mathcal{A}} % Generic constant (EVT asymptotics) \newcommand{\RemainderConst}{\mathcal{C}} % Little-o asymptotic notation \newcommand{\LittleO}{o} % Moment order (EVT) \newcommand{\MomentOrder}{\mathscr{m}} % Doubled sample size (EVT block maxima) \newcommand{\DoubleSample}{D} % GEV normalizing location constant \newcommand{\GEVloc}{a} % GEV normalizing scale constant \newcommand{\GEVscale}{b} % Negative return (loss, EVT context) \newcommand{\NegativeReturn}{L} % --- Copulas ------------------------------------------------------------- % Copula function \newcommand{\Copula}{\mathcal{C}} % Copula density \newcommand{\CopulaDensity}{c} % Generator function (Archimedean copulas) \newcommand{\generator}{\varphi} % Copula dependence parameter \newcommand{\CopulaParam}{\theta} % Copula marginal parameter set \newcommand{\MarginalParamSet}{\eta} % Lower tail dependence coefficient \newcommand{\LowerTailDep}{\lambda_L} % Upper tail dependence coefficient \newcommand{\UpperTailDep}{\lambda_U} % Marginal CDF (second variable) \newcommand{\MarginalCDF}{G} % Joint density function \newcommand{\JointDensity}{h} % Marginal density function \newcommand{\MarginalDensity}{g} % Joint distribution function \newcommand{\JointCDF}{H} % Gaussian copula correlation parameter \newcommand{\CopulaCorr}{\rho} % --- Multivariate -------------------------------------------------------- % Diagonal matrix of conditional volatilities \newcommand{\DiagVolD}{\bm{D}} % Idiosyncratic variance matrix (diagonal, factor models) \newcommand{\IdioVarMatrix}{\bm{\Psi}} % Covariance matrix entry (two indices) \newcommand{\CovElement}{\sigma} % Auxiliary matrix in DCC dynamics \newcommand{\DCCauxQ}{\bm{Q}} % BEKK constant matrix \newcommand{\BEKKconst}{\bm{\Omega}} % BEKK ARCH parameter matrix \newcommand{\BEKKarch}{\bm{A}} % BEKK GARCH parameter matrix \newcommand{\BEKKgarch}{\bm{B}} % DCC rescaling diagonal matrix \newcommand{\DCCrescaleZ}{\bm{Z}} % DCC auxiliary matrix element \newcommand{\DCCelement}{q} % Factor loadings matrix (loadings/eigenvectors of the covariance matrix) \newcommand{\FactorLoadings}{\bm{\Lambda}} % Factor subscript label \newcommand{\factor}{\text{factor}} % PCA eigenvalue \newcommand{\Eigenvalue}{\lambda} % Number of factors in factor model \newcommand{\NumberFactors}{m} % Factor return vector \newcommand{\FactorReturn}{\bm{g}} % PCA factor score (scalar; not an eigenvector) \newcommand{\PCAFactor}{F} % BEKK cross-covariance coefficient \newcommand{\BEKKcross}{\delta} % Factor loading element \newcommand{\FactorLoading}{\Lambda} % DCC correlation persistence parameter \newcommand{\DCCxi}{\xi} % DCC news coefficient (weight on recent shocks) \newcommand{\DCCzeta}{\zeta} % --- Simulation ---------------------------------------------------------- % Number of simulation paths \newcommand{\NumberSims}{B} % Cholesky factor of covariance matrix \newcommand{\Cholesky}{\bm{L}} % Uniform random number \newcommand{\UniformDraw}{\tilde{u}} % --- Backtesting --------------------------------------------------------- % Violation ratio (observed/expected violations) \DeclareMathOperator{\ViolRatio}{VR} % Stressed Value at Risk \DeclareMathOperator{\StressedVaR}{SVaR} % QLIKE loss function for variance forecasts \DeclareMathOperator{\QLIKE}{QLIKE} % Mean squared error (forecast loss function) \DeclareMathOperator{\MSE}{MSE} % Mean absolute error (forecast loss function) \DeclareMathOperator{\MAE}{MAE} % Markov transition probability \newcommand{\TransProb}{\pi} % Exception indicator: 1 if y_t < -VaR_t \newcommand{\ExceptionInd}{\eta} % Exception count \newcommand{\ExceptionCount}{\upsilon} % Probability Integral Transform \newcommand{\ProbIntegralTransform}{\hat{u}} % Quantile score (pinball loss function) \newcommand{\QuantileScore}{\mathcal{S}} % Kolmogorov-Smirnov test statistic \newcommand{\KSstat}{D} % Test statistic (generic base letter) \newcommand{\TestStat}{J} % --- General ------------------------------------------------------------- % Regression intercept \newcommand{\RegressionIntercept}{a} % Regression slope \newcommand{\RegressionSlope}{\beta} % Risk factor \newcommand{\RiskFactor}{x} % Forecast horizon \newcommand{\ForecastHorizon}{h} % Block length (bootstrap) \newcommand{\BlockLength}{\ell} % Capital tau variant \newcommand{\DeliveryTime}{\Upsilon} % Today's calendar time in years (option pricing input; distinct from trading-date index t) \newcommand{\CalendarTime}{t^*} % Probability measure \newcommand{\ProbMeasure}{\mathbb{P}} % Risk-neutral measure \newcommand{\RiskNeutral}{\mathbb{Q}} % Pricing function (Black-Scholes, bond pricing, etc.) \newcommand{\PricingFn}{V} % Indicator function \newcommand{\Indicator}{\bm{1}} % Vector of ones \newcommand{\OnesVector}{\bm{1}} % Risk factor sensitivity (first-order P&L) \newcommand{\Sensitivity}{\delta} % Second-order sensitivity (convexity/gamma) \newcommand{\ConvexitySens}{\gamma} % Output floor percentage (Basel) \newcommand{\OutputFloor}{\alpha} % --- Subscript Labels ---------------------------------------------------- % Subscript label: annualized \newcommand{\Annual}{a} % Subscript label: implied \newcommand{\Implied}{I} % Subscript label: portfolio \newcommand{\Portfolio}{\pi} % --- Text Abbreviations -------------------------------------------- % S&P 500 index \newcommand{\SP}{\text{S\&P-500}} % Student-t distribution (text) \newcommand{\St}{\text{Student-t}} % Geopolitical Risk index (Caldara and Iacoviello) \newcommand{\GPR}{\text{GPR}} \]

15  Simulations

We use simulations for three reasons. They test models, they show how models behave and they replace awkward mathematics with a numerical answer. Advanced applications of simulations in risk management, including Value-at-Risk calculations, are covered in Chapter 25.

When referring to simulations, we often use the term Monte Carlo after the Monte Carlo district of Monaco, a statelet on the Mediterranean coast surrounded by France and famous for its casino. We will use the terms simulation, Monte Carlo and MC interchangeably.

When doing simulations, we ask a computer algorithm to replicate the real world. But simulations have hard limits.

The first limit is simple. Computers are deterministic, so they do not produce true randomness. Instead, they produce something called a pseudorandom number, using an algorithm called a pseudorandom number generator. That is clumsy, so from here on we use the term random number generator or RNG.

A random number generator creates a sequence of numbers \(\UniformDraw_1, \UniformDraw_2, \ldots\) and when it has gone through the sequence, it starts again from the beginning. Therefore, if we know the internal state of the generator, we also know every subsequent draw \(\UniformDraw_{i+1}, \UniformDraw_{i+2}, \ldots\) in the sequence.

NoteThe same property outside finance

In cryptography, if attackers can determine the internal state of an RNG used for key generation, they may be able to predict future keys and compromise security. The predictability that makes simulation reproducible is exactly what makes a cipher breakable.

The second limit is that a simulation is a crude approximation of reality. If a model appears to pass or fail inside the simulation, that verdict need not survive contact with the market.

The length of the sequence before the RNG repeats is known as the period. The default random number generators in R and in Python’s random module use the Mersenne Twister algorithm, with a period of \(2^{19937}-1\), approximately \(10^{6002}\). NumPy’s legacy interface, np.random, used throughout this chapter, also employs the Mersenne Twister, while NumPy’s newer default_rng() interface uses PCG64 instead. Julia uses Xoshiro256++, with a period of \(2^{256}-1\). All are more than sufficient for almost every possible application.

The term seed refers to a particular starting point in the sequence of random numbers. The commands for setting the seed are set.seed() in R, np.random.seed() in Python and Random.seed!() in Julia. If one sets the seed to 1, one always gets the same random numbers. Setting the seed to 2 does not start the sequence at its second element. It starts from a different point on the loop altogether.

15.1 Data and libraries

Simulation needs a random number generator, which is built in, and a copula package, which is not.

library(ggplot2)
library(MASS)
library(copula)
import numpy as np
import pandas as pd
from scipy import stats
from scipy.stats import norm
from plotnine import ggplot, aes, geom_line, geom_point, geom_histogram, geom_vline
from plotnine import labs, theme, theme_minimal, annotate, coord_cartesian
import os

os.makedirs("_figs", exist_ok=True)
using Random, Statistics, Distributions, DataFrames
using TidierPlots, CairoMakie
using Copulas

isdir("_figs") || mkdir("_figs");

15.2 Random numbers

15.2.1 Basics

In Section 14.3, we discussed the various distributions built into each language. To obtain simulated random numbers from a standard normal distribution:

rnorm(n = 1)
rnorm(n = 5)
[1] 0.3724005
[1]  0.29848646 -0.85994134 -0.55162714 -0.01996858 -0.82093295

Every time we call the function, we get a different random number:

rnorm(n = 1)
rnorm(n = 1)
[1] 0.2400226
[1] 0.1131
np.random.normal()
np.random.normal(size=5)

Every time we call the function, we get a different random number:

print(np.random.normal())
print(np.random.normal())
-1.5928895261117963
0.5499585872184823
randn()
randn(5)

Every time we call the function, we get a different random number:

println(randn())
println(randn())
-0.27752111502844873
-0.9388843100637846

15.2.2 Seed

Most of the time we want the same random numbers each time we run an algorithm.

Since random number generators create a sequence of numbers that repeats, if we fix a place in the sequence, we always get the same random numbers. The way to do that is to set the seed.

rnorm(3)
rnorm(3)
set.seed(666)
rnorm(3)
set.seed(666)
rnorm(3)
[1]  0.5757169 -0.4072666 -0.9656123
[1]  0.5719939 -1.8196830  0.2065523
[1]  0.7533110  2.0143547 -0.3551345
[1]  0.7533110  2.0143547 -0.3551345
print(np.random.normal(size=3))
print(np.random.normal(size=3))
np.random.seed(666)
print(np.random.normal(size=3))
np.random.seed(666)
print(np.random.normal(size=3))
[-0.67446082 -0.39638296 -0.67915395]
[-2.47768163 -0.33524818 -0.89908978]
[0.82418808 0.479966   1.17346801]
[0.82418808 0.479966   1.17346801]
println(randn(3))
println(randn(3))
Random.seed!(666);
println(randn(3))
Random.seed!(666);
println(randn(3))
[1.3085509795079397, -0.6031101292935653, 0.3664946998032432]
[0.054970529246164, 1.7418421289806596, 0.7044954116027843]
[-1.0755336429985016, -0.4358053366724109, -0.7459634539487185]
[-1.0755336429985016, -0.4358053366724109, -0.7459634539487185]

15.3 Distributions

We use four distributions for simulation — normal, Student-t, chi-square and Bernoulli. See Section 14.3 for distribution functions (PDF, CDF, quantile). Here we focus on random number generation.

cat("Normal:", round(rnorm(n = 1), 4), "\n")
cat("Student-t:", round(rt(n = 1, df = 3), 4), "\n")
cat("Chi-square:", round(rchisq(n = 1, df = 2), 4), "\n")
cat("Bernoulli:", rbinom(n = 1, size = 1, prob = 0.5), "\n")
Normal: 2.0282 
Student-t: -1.9686 
Chi-square: 4.3524 
Bernoulli: 1
print(f"Normal: {np.random.normal():.4f}")
print(f"Student-t: {np.random.standard_t(df=3):.4f}")
print(f"Chi-square: {np.random.chisquare(df=2):.4f}")
print(f"Bernoulli: {np.random.binomial(n=1, p=0.5)}")
Normal: 0.9090
Student-t: -0.6826
Chi-square: 1.4188
Bernoulli: 0
println("Normal: ", round(rand(Normal()), digits=4))
println("Student-t: ", round(rand(TDist(3)), digits=4))
println("Chi-square: ", round(rand(Chisq(2)), digits=4))
println("Bernoulli: ", rand(Binomial(1, 0.5)))
Normal: -0.0324
Student-t: -4.8805
Chi-square: 0.5503
Bernoulli: 1

We can also plot some random numbers.

df_norm = data.frame(x = 1:100, y = rnorm(n = 100))
ggplot(df_norm, aes(x = x, y = y)) +
  geom_point(colour = "blue") +
  labs(title = "Normal") +
  theme_minimal()

df_t = data.frame(x = 1:100, y = rt(n = 100, df = 2))
ggplot(df_t, aes(x = x, y = y)) +
  geom_point(colour = "blue") +
  labs(title = "Student-t (df=2)") +
  theme_minimal()

df_chi = data.frame(x = 1:100, y = rchisq(n = 100, df = 3))
ggplot(df_chi, aes(x = x, y = y)) +
  geom_point(colour = "blue") +
  labs(title = "Chi-square (df=3)") +
  theme_minimal()

df_binom = data.frame(x = 1:100, y = rbinom(n = 100, size = 1, prob = 0.5))
ggplot(df_binom, aes(x = x, y = y)) +
  geom_point(colour = "blue") +
  labs(title = "Bernoulli") +
  theme_minimal()

df_norm = pd.DataFrame({'x': range(1, 101), 'y': np.random.normal(size=100)})
p = (ggplot(df_norm, aes(x='x', y='y'))
 + geom_point(colour="blue")
 + labs(title="Normal")
 + theme_minimal())
p.save("_figs/sim_norm_py.png", width=6, height=4, dpi=100, verbose=False)

df_t = pd.DataFrame({'x': range(1, 101), 'y': np.random.standard_t(df=2, size=100)})
p = (ggplot(df_t, aes(x='x', y='y'))
 + geom_point(colour="blue")
 + labs(title="Student-t (df=2)")
 + theme_minimal())
p.save("_figs/sim_t_py.png", width=6, height=4, dpi=100, verbose=False)

df_chi = pd.DataFrame({'x': range(1, 101), 'y': np.random.chisquare(df=3, size=100)})
p = (ggplot(df_chi, aes(x='x', y='y'))
 + geom_point(colour="blue")
 + labs(title="Chi-square (df=3)")
 + theme_minimal())
p.save("_figs/sim_chi_py.png", width=6, height=4, dpi=100, verbose=False)

df_binom = pd.DataFrame({'x': range(1, 101), 'y': np.random.binomial(n=1, p=0.5, size=100)})
p = (ggplot(df_binom, aes(x='x', y='y'))
 + geom_point(colour="blue")
 + labs(title="Bernoulli")
 + theme_minimal())
p.save("_figs/sim_binom_py.png", width=6, height=4, dpi=100, verbose=False)

df_norm = DataFrame(x = 1:100, y = randn(100));
p = ggplot(df_norm, @aes(x = x, y = y)) +
    geom_point(color = "blue") +
    labs(title = "Normal") +
    theme_minimal();
ggsave("_figs/sim_norm_jl.png", p);

df_t = DataFrame(x = 1:100, y = rand(TDist(2), 100));
p = ggplot(df_t, @aes(x = x, y = y)) +
    geom_point(color = "blue") +
    labs(title = "Student-t (df=2)") +
    theme_minimal();
ggsave("_figs/sim_t_jl.png", p);

df_chi = DataFrame(x = 1:100, y = rand(Chisq(3), 100));
p = ggplot(df_chi, @aes(x = x, y = y)) +
    geom_point(color = "blue") +
    labs(title = "Chi-square (df=3)") +
    theme_minimal();
ggsave("_figs/sim_chi_jl.png", p);

df_binom = DataFrame(x = 1:100, y = rand(Binomial(1, 0.5), 100));
p = ggplot(df_binom, @aes(x = x, y = y)) +
    geom_point(color = "blue") +
    labs(title = "Bernoulli") +
    theme_minimal();
ggsave("_figs/sim_binom_jl.png", p);

A combined plot reveals the distributions more clearly.

n = 100
df_all = data.frame(
  x = rep(1:n, 4),
  y = c(rnorm(n), rt(n, df = 3), rchisq(n, df = 2), rbinom(n, size = 1, prob = 0.5)),
  distribution = rep(c("Normal", "Student-t", "Chi-square", "Bernoulli"), each = n)
)

ggplot(df_all, aes(x = x, y = y, colour = distribution)) +
  geom_point() +
  labs(x = "", y = "", colour = NULL) +
  theme_minimal() +
  theme(legend.position = "top")

n = 100
df_all = pd.DataFrame({
    'x': list(range(1, n+1)) * 4,
    'y': np.concatenate([
        np.random.normal(size=n),
        np.random.standard_t(df=3, size=n),
        np.random.chisquare(df=2, size=n),
        np.random.binomial(n=1, p=0.5, size=n)
    ]),
    'distribution': ['Normal']*n + ['Student-t']*n + ['Chi-square']*n + ['Bernoulli']*n
})

p = (ggplot(df_all, aes(x='x', y='y', colour='distribution'))
 + geom_point()
 + labs(x="", y="", colour="")
 + theme_minimal()
 + theme(legend_position="top"))
p.save("_figs/sim_all_py.png", width=6, height=4, dpi=100, verbose=False)

n = 100;
df_all = DataFrame(
    x = repeat(1:n, 4),
    y = vcat(randn(n), rand(TDist(3), n), rand(Chisq(2), n), rand(Binomial(1, 0.5), n)),
    distribution = vcat(fill("Normal", n), fill("Student-t", n), fill("Chi-square", n), fill("Bernoulli", n))
);

p = ggplot(df_all, @aes(x = x, y = y, color = distribution)) +
    geom_point() +
    labs(x = "", y = "") +
    theme_minimal();
ggsave("_figs/sim_all_jl.png", p);

15.4 Number of simulations

A common problem in simulations is to determine the number of necessary simulations. Pick too few, and one gets an inaccurate simulation estimate. Pick too many, and one wastes resources.

By the Central Limit Theorem, the standard error of the Monte Carlo estimate decreases at rate \(O(1/\sqrt{\NumberSims})\), provided the simulated quantity has finite variance. The rate does not depend on the dimension of the problem, though the constant depends on the variance of the quantity being estimated. Here \(\NumberSims\) corresponds to S in the code below.

Below, we pick six simulation sizes and see how well their mean and standard deviation compare to the known true value (mean=1, sd=2).

S = c(5, 25, 100, 1e3, 1e4, 1e5)
results = data.frame(n = S, mean = NA, sd = NA)
for(i in seq_along(S)){
  x = rnorm(n = S[i], mean = 1, sd = 2)
  results$mean[i] = round(mean(x), 4)
  results$sd[i] = round(sd(x), 4)
}
results$meanError = round(results$mean - 1, 4)
results$sdError = round(results$sd - 2, 4)
print(results)
       n   mean     sd meanError sdError
1      5 0.8619 1.8499   -0.1381 -0.1501
2     25 0.9884 2.1266   -0.0116  0.1266
3    100 1.1234 1.9187    0.1234 -0.0813
4   1000 0.9880 1.9825   -0.0120 -0.0175
5  10000 1.0000 2.0056    0.0000  0.0056
6 100000 1.0027 1.9973    0.0027 -0.0027
S = [5, 25, 100, 1000, 10000, 100000]
results = []
for n in S:
    x = np.random.normal(loc=1, scale=2, size=n)
    results.append({'n': n, 'mean': round(np.mean(x), 4), 'sd': round(np.std(x, ddof=1), 4)})
df_results = pd.DataFrame(results)
df_results['meanError'] = round(df_results['mean'] - 1, 4)
df_results['sdError'] = round(df_results['sd'] - 2, 4)
print(df_results.to_string(index=False))
     n   mean     sd  meanError  sdError
     5 1.0602 0.8846     0.0602  -1.1154
    25 1.3348 1.6687     0.3348  -0.3313
   100 0.7176 1.9888    -0.2824  -0.0112
  1000 1.0109 1.9673     0.0109  -0.0327
 10000 1.0037 1.9889     0.0037  -0.0111
100000 1.0101 2.0000     0.0101   0.0000
S = [5, 25, 100, 1000, 10000, 100000];
results = DataFrame(n = S, mean = zeros(length(S)), sd = zeros(length(S)));
for (i, n) in enumerate(S)
    x = rand(Normal(1, 2), n);
    results.mean[i] = round(mean(x), digits=4);
    results.sd[i] = round(std(x), digits=4);
end
results.meanError = round.(results.mean .- 1, digits=4);
results.sdError = round.(results.sd .- 2, digits=4);
println(results)
6×5 DataFrame
 Row │ n       mean     sd       meanError  sdError
     │ Int64   Float64  Float64  Float64    Float64
─────┼──────────────────────────────────────────────
   1 │      5   0.8817   2.498     -0.1183   0.498
   2 │     25   0.5555   2.1691    -0.4445   0.1691
   3 │    100   1.0394   2.0443     0.0394   0.0443
   4 │   1000   0.9102   1.9519    -0.0898  -0.0481
   5 │  10000   1.0147   1.9914     0.0147  -0.0086
   6 │ 100000   1.0041   1.9905     0.0041  -0.0095

15.5 Random walk

It is easy to simulate a random walk. In the example below, we set the mean to 0 and the standard deviation to 1%, which is approximately correct for most stock returns. We also use the IID normal distribution, which is not correct for stock returns. A simple return below -100% would produce a negative price, but at this standard deviation that is a draw beyond 100 standard deviations and does not occur in practice. Simulating log returns and exponentiating, as the option-pricing section below does, avoids the bound entirely.

N = 1000
x = rnorm(N, mean = 0, sd = 0.01)
p = cumprod(c(1, x + 1))
df_rw = data.frame(t = 0:N, price = p)
ggplot(df_rw, aes(x = t, y = price)) +
  geom_line() +
  labs(x = "", y = "Price") +
  theme_minimal()

N = 1000
x = np.random.normal(loc=0, scale=0.01, size=N)
p = np.cumprod(np.concatenate(([1], x + 1)))
df_rw = pd.DataFrame({'t': range(0, N+1), 'price': p})

p_plot = (ggplot(df_rw, aes(x='t', y='price'))
 + geom_line()
 + labs(x="", y="Price")
 + theme_minimal())
p_plot.save("_figs/sim_rw_py.png", width=6, height=4, dpi=100, verbose=False)

N = 1000;
x = randn(N) * 0.01;
p = cumprod(vcat(1, x .+ 1));
df_rw = DataFrame(t = 0:N, price = p);

pl = ggplot(df_rw, @aes(x = t, y = price)) +
    geom_line() +
    labs(x = "", y = "Price") +
    theme_minimal();
ggsave("_figs/sim_rw_jl.png", pl);

We can also do several random walks and plot them on the same figure.

N = 1000
R = 4
df_walks = do.call(rbind, lapply(1:R, function(i) {
  x = rnorm(N, mean = 0, sd = 0.01)
  p = cumprod(c(1, x + 1))
  data.frame(t = 0:N, price = p, walk = factor(i))
}))

ggplot(df_walks, aes(x = t, y = price, colour = walk)) +
  geom_line() +
  labs(x = "", y = "Price", colour = NULL) +
  theme_minimal()

N = 1000
R = 4
walks = []
for i in range(R):
    x = np.random.normal(loc=0, scale=0.01, size=N)
    p = np.cumprod(np.concatenate(([1], x + 1)))
    walks.append(pd.DataFrame({'t': range(0, N+1), 'price': p, 'walk': str(i+1)}))
df_walks = pd.concat(walks)

p_plot = (ggplot(df_walks, aes(x='t', y='price', colour='walk'))
 + geom_line()
 + labs(x="", y="Price", colour="")
 + theme_minimal())
p_plot.save("_figs/sim_walks_py.png", width=6, height=4, dpi=100, verbose=False)

N = 1000;
R = 4;
df_walks = vcat([begin
    x = randn(N) * 0.01;
    p = cumprod(vcat(1, x .+ 1));
    DataFrame(t = 0:N, price = p, walk = fill(string(i), N+1))
end for i in 1:R]...);

pl = ggplot(df_walks, @aes(x = t, y = price, color = walk)) +
    geom_line() +
    labs(x = "", y = "Price") +
    theme_minimal();
ggsave("_figs/sim_walks_jl.png", pl);

The random walk above simulates a price path by compounding IID returns. Option pricing extends this idea. Instead of tracing a path, we simulate the terminal asset price under a specific model, such as geometric Brownian motion, and compute the discounted payoff. By averaging over many such simulated payoffs, we obtain a Monte Carlo estimate of the option price.

15.6 Pricing options

This is the cleanest test of Monte Carlo pricing. Black-Scholes gives the exact option value, so any gap comes from simulation error.

Start with a function for the Black-Scholes price and set some parameters.

bs = function(X, P, r, sigma, T){
  d1 = (log(P/X) + (r + 0.5*sigma^2)*T) / (sigma*sqrt(T))
  d2 = d1 - sigma*sqrt(T)
  Call = P*pnorm(d1) - X*exp(-r*T)*pnorm(d2)
  Put = X*exp(-r*T)*pnorm(-d2) - P*pnorm(-d1)
  return(list(Call = Call, Put = Put))
}

P0 = 50; sigma = 0.2; r = 0.05; T = 0.5; X = 40
bsprice = bs(X, P0, r, sigma, T)
cat("Call:", round(bsprice$Call, 4), "\n")
cat("Put:", round(bsprice$Put, 4), "\n")
Call: 11.0873 
Put: 0.0997
def bs(X, P, r, sigma, T):
    d1 = (np.log(P/X) + (r + 0.5*sigma**2)*T) / (sigma*np.sqrt(T))
    d2 = d1 - sigma*np.sqrt(T)
    Call = P*norm.cdf(d1) - X*np.exp(-r*T)*norm.cdf(d2)
    Put = X*np.exp(-r*T)*norm.cdf(-d2) - P*norm.cdf(-d1)
    return {'Call': Call, 'Put': Put}

P0 = 50; sigma = 0.2; r = 0.05; T = 0.5; X = 40
bsprice = bs(X, P0, r, sigma, T)
print(f"Call: {bsprice['Call']:.4f}")
print(f"Put: {bsprice['Put']:.4f}")
Call: 11.0873
Put: 0.0997
function bs(X, P, r, sigma, T)
    d1 = (log(P/X) + (r + 0.5*sigma^2)*T) / (sigma*sqrt(T));
    d2 = d1 - sigma*sqrt(T);
    Call = P*cdf(Normal(), d1) - X*exp(-r*T)*cdf(Normal(), d2);
    Put = X*exp(-r*T)*cdf(Normal(), -d2) - P*cdf(Normal(), -d1);
    return (Call = Call, Put = Put)
end

P0 = 50; sigma = 0.2; r = 0.05; T = 0.5; X = 40;
bsprice = bs(X, P0, r, sigma, T);
println("Call: ", round(bsprice.Call, digits=4))
println("Put: ", round(bsprice.Put, digits=4))
Call: 11.0873
Put: 0.0997

We then create a simulation function that returns the discounted payoff draws for a call option, and test it.

sim = function(S = 1e6, P0 = 50, sigma = 0.2, r = 0.05, T = 0.5, X = 40, seed = 666){
  set.seed(seed)
  F = P0 * exp(r * T)
  ysim = rnorm(S, -0.5 * sigma^2 * T, sigma * sqrt(T))
  F = F * exp(ysim)
  SP = pmax(F - X, 0)
  fsim = SP * exp(-r * T)
  return(fsim)
}

cat("Mean:", round(mean(sim(S = 10)), 4), "\n")
Mean: 11.1472
def sim(S=1000000, P0=50, sigma=0.2, r=0.05, T=0.5, X=40, seed=666):
    np.random.seed(seed)
    F = P0 * np.exp(r * T)
    ysim = np.random.normal(-0.5 * sigma**2 * T, sigma * np.sqrt(T), S)
    F = F * np.exp(ysim)
    SP = np.maximum(F - X, 0)
    fsim = SP * np.exp(-r * T)
    return fsim

print(f"Mean: {np.mean(sim(S=10)):.4f}")
Mean: 11.9051
function sim(S=1000000; P0=50, sigma=0.2, r=0.05, T=0.5, X=40, seed=666)
    Random.seed!(seed);
    F = P0 * exp(r * T);
    ysim = rand(Normal(-0.5 * sigma^2 * T, sigma * sqrt(T)), S);
    F = F .* exp.(ysim);
    SP = max.(F .- X, 0);
    fsim = SP .* exp(-r * T);
    return fsim
end

println("Mean: ", round(mean(sim(10)), digits=4))
Mean: 12.2977

If we let the number of simulations vary, we can see convergence to the true value:

cat(paste0("Black-Scholes: ", round(bsprice$Call, 4), "\n"))
for(S in c(10, 50, 100, 1000, 10000)){
  cat(paste0(S, ": ", round(mean(sim(S = S)), 4), "\n"))
}
Black-Scholes: 11.0873
10: 11.1472
50: 10.7544
100: 10.6525
1000: 10.9352
10000: 11.1966
print(f"Black-Scholes: {bsprice['Call']:.4f}")
for S in [10, 50, 100, 1000, 10000]:
    print(f"{S}: {np.mean(sim(S=S)):.4f}")
Black-Scholes: 11.0873
10: 11.9051
50: 10.5451
100: 10.9802
1000: 11.0301
10000: 10.9720
println("Black-Scholes: ", round(bsprice.Call, digits=4))
for S in [10, 50, 100, 1000, 10000]
    println(S, ": ", round(mean(sim(S; seed=666)), digits=4))
end
Black-Scholes: 11.0873
10: 12.2977
50: 12.8988
100: 12.3689
1000: 11.3754
10000: 11.1202

Below, we run many more simulations and plot their histogram with the true value superimposed.

fsim = sim()
df_hist = data.frame(price = fsim)

ggplot(df_hist, aes(x = price)) +
  geom_histogram(aes(y = after_stat(density)), breaks = seq(0, 35, length.out = 101), fill = "red", colour = "white") +
  geom_vline(xintercept = bsprice$Call, colour = "darkred", linewidth = 1) +
  annotate("text", x = bsprice$Call, y = 0.11, label = "Black-Scholes\ncall price", hjust = -0.1) +
  coord_cartesian(ylim = c(0, 0.12)) +
  labs(x = "Discounted call payoff draws", y = "Density") +
  theme_minimal()

from plotnine import after_stat

fsim = sim()
df_hist = pd.DataFrame({'price': fsim})
breaks = np.linspace(0, 35, 101)

p = (ggplot(df_hist, aes(x='price'))
 + geom_histogram(aes(y=after_stat("density")), breaks=breaks, fill="red", colour="white")
 + geom_vline(xintercept=bsprice['Call'], colour="darkred", size=1)
 + annotate("text", x=bsprice['Call'], y=0.11, label="Black-Scholes\ncall price", ha="left")
 + coord_cartesian(ylim=(0, 0.12))
 + labs(x="Discounted call payoff draws", y="Density")
 + theme_minimal())
p.save("_figs/sim_hist_py.png", width=6, height=4, dpi=100, verbose=False)

using Random, Distributions, DataFrames, TidierPlots
P0 = 50; sigma = 0.2; r = 0.05; T = 0.5; X = 40;

function sim(S=1000000; P0=50, sigma=0.2, r=0.05, T=0.5, X=40, seed=666)
    Random.seed!(seed);
    F = P0 * exp(r * T);
    ysim = rand(Normal(-0.5 * sigma^2 * T, sigma * sqrt(T)), S);
    F = F .* exp.(ysim);
    SP = max.(F .- X, 0);
    fsim = SP .* exp(-r * T);
    return fsim
end

fsim = sim();
df_hist = DataFrame(price = fsim);

bscall = bsprice.Call;
edges = range(0, 35, length=101);
counts = [count(x -> edges[i] <= x < edges[i+1], fsim) for i in 1:100];
binwidth = edges[2] - edges[1];
density_vals = counts ./ (length(fsim) * binwidth);
mids = [(edges[i] + edges[i+1]) / 2 for i in 1:100];

df_hist_bars = DataFrame(mid = mids, density = density_vals);
df_line = DataFrame(x = [bscall, bscall], y = [0.0, maximum(density_vals)]);
df_label = DataFrame(x = [bscall], y = [maximum(density_vals)], label = ["Black-Scholes\ncall price"]);

p = ggplot(df_hist_bars, @aes(x = mid, y = density)) +
    geom_col(color = "red", width = binwidth) +
    geom_line(@aes(x = x, y = y), data = df_line, color = "darkred", linewidth = 1) +
    geom_text(@aes(x = x, y = y, label = label), data = df_label) +
    labs(x = "Discounted call payoff draws", y = "Density") +
    theme_minimal();
ggsave("_figs/sim_hist_jl.png", p);

The mean of the discounted payoff draws is the Monte Carlo estimate of the call price. Confidence bounds for that estimate come from the standard error of the mean across draws, not from the spread of the draws themselves. We leave the calculation as an exercise.

Chapter 25 covers further applications of simulation in risk management, including portfolio Value-at-Risk calculations.

15.7 Multivariate simulation

Everything so far has simulated one risk factor. The second application of the same machinery is several at once, where what has to be simulated is not just each series but how they move together.

In a crisis, assets that looked diversified start falling together, and the dependence is stronger on the downside than on the upside. Copulas address this by separating the dependence structure from the marginal distributions, allowing each to be modelled independently. Whether a copula captures joint crashes depends on which one is chosen. The Gaussian copula below has no asymptotic tail dependence, while the Clayton copula produces the lower-tail clustering visible in its scatter plot. We return to multivariate simulation in Chapter 25.

The word asymptotic is doing real work there. Tail dependence is a limit — the probability that one asset breaches its \(\probability\)-quantile given that the other has, as \(\probability \to 0\). For the Gaussian copula with any correlation below one, that limit is zero.

It does not follow that joint losses are rare at the thresholds we actually use. A Gaussian copula with \(\correlation = 0.7\) still puts far more probability on both assets breaching their 1% quantiles together than independence would, and 1% is not the limit. What the zero limit says is that the clustering thins out as we go further into the tail, and eventually behaves as if the assets were independent. The practical question is whether it thins out before or after the loss level we care about.

We start with the multivariate normal. To simulate correlated values:

The library MASS allows us to simulate multivariate normals with mvrnorm.

mu = c(0, 0)
Sigma = matrix(c(1, 0.8, 0.8, 1), ncol = 2)
mv_data = mvrnorm(n = 1000, mu = mu, Sigma = Sigma)
df_mv = data.frame(x = mv_data[, 1], y = mv_data[, 2])

ggplot(df_mv, aes(x = x, y = y)) +
  geom_point(colour = "blue", alpha = 0.5) +
  labs(title = "Multivariate Normal Simulation", x = "", y = "") +
  theme_minimal()

NumPy provides multivariate_normal for correlated simulations.

mu = [0, 0]
cov_matrix = [[1, 0.8], [0.8, 1]]
mv_data = np.random.multivariate_normal(mu, cov_matrix, 1000)
df_mv = pd.DataFrame({'x': mv_data[:, 0], 'y': mv_data[:, 1]})

p = (ggplot(df_mv, aes(x='x', y='y'))
 + geom_point(colour="blue", alpha=0.5)
 + labs(title="Multivariate Normal Simulation", x="", y="")
 + theme_minimal())
p.save("_figs/sim_mv_py.png", width=6, height=4, dpi=100, verbose=False)

Julia uses MvNormal from Distributions.

mu = [0.0, 0.0];
Sigma = [1.0 0.8; 0.8 1.0];
mv_data = rand(MvNormal(mu, Sigma), 1000)';
df_mv = DataFrame(x = mv_data[:, 1], y = mv_data[:, 2]);

p = ggplot(df_mv, @aes(x = x, y = y)) +
    geom_point(color = "blue", alpha = 0.5) +
    labs(title = "Multivariate Normal Simulation", x = "", y = "") +
    theme_minimal();
ggsave("_figs/sim_mv_jl.png", p);

15.7.1 Copulas

A copula lets us model two things separately — how each variable behaves on its own and how the variables move together.

Library copula provides various copula types.

normal_cop = normalCopula(param = 0.7, dim = 2)
cop_sample = rCopula(1000, normal_cop)
df_cop_unif = data.frame(x = cop_sample[, 1], y = cop_sample[, 2])

ggplot(df_cop_unif, aes(x = x, y = y)) +
  geom_point(colour = "purple", alpha = 0.5) +
  labs(title = "Gaussian Copula (uniform margins)", x = "", y = "") +
  theme_minimal()

clayton_cop = claytonCopula(param = 2, dim = 2)
u = rCopula(1000, clayton_cop)
df_clayton = data.frame(U1 = u[, 1], U2 = u[, 2])

ggplot(df_clayton, aes(x = U1, y = U2)) +
  geom_point(colour = "blue", alpha = 0.5) +
  labs(title = "Clayton Copula") +
  theme_minimal()

We can construct a Gaussian copula manually using the correlation structure.

# Gaussian copula via correlation
rho = 0.7
z = np.random.multivariate_normal([0, 0], [[1, rho], [rho, 1]], 1000)
u = norm.cdf(z)  # Transform to uniform margins
df_cop = pd.DataFrame({'x': u[:, 0], 'y': u[:, 1]})

p = (ggplot(df_cop, aes(x='x', y='y'))
 + geom_point(colour="purple", alpha=0.5)
 + labs(title="Gaussian Copula (uniform margins)", x="", y="")
 + theme_minimal())
p.save("_figs/sim_cop_py.png", width=6, height=4, dpi=100, verbose=False)

Clayton copula using the conditional distribution method:

# Clayton copula via conditional inverse
theta = 2.0
n = 1000
u1 = np.random.uniform(size=n)
v = np.random.uniform(size=n)
u2 = (u1**(-theta) * (v**(-theta/(theta+1)) - 1) + 1)**(-1/theta)
df_clayton = pd.DataFrame({'U1': u1, 'U2': u2})

p = (ggplot(df_clayton, aes(x='U1', y='U2'))
 + geom_point(colour="blue", alpha=0.5)
 + labs(title="Clayton Copula")
 + theme_minimal())
p.save("_figs/sim_clayton_py.png", width=6, height=4, dpi=100, verbose=False)

# Gaussian copula via correlation
rho = 0.7;
Sigma = [1.0 rho; rho 1.0];
z = rand(MvNormal([0.0, 0.0], Sigma), 1000)';
u = cdf.(Normal(), z);
df_cop = DataFrame(x = u[:, 1], y = u[:, 2]);

p = ggplot(df_cop, @aes(x = x, y = y)) +
    geom_point(color = "purple", alpha = 0.5) +
    labs(title = "Gaussian Copula (uniform margins)", x = "", y = "") +
    theme_minimal();
ggsave("_figs/sim_cop_jl.png", p);

Clayton copula using Copulas.jl:

clayton = ClaytonCopula(2, 2.0);
u = rand(clayton, 1000)';
df_clayton = DataFrame(U1 = u[:, 1], U2 = u[:, 2]);

p = ggplot(df_clayton, @aes(x = U1, y = U2)) +
    geom_point(color = "blue", alpha = 0.5) +
    labs(title = "Clayton Copula") +
    theme_minimal();
ggsave("_figs/sim_clayton_jl.png", p);

15.8 Exercise

  1. Use simulations to get the confidence bounds for the simulation estimates.
  2. Draw from the Clayton copula at a range of dependence parameters and count how often both series breach their 1% quantile on the same draw. Compare with the Gaussian copula at the correlation that produces the same overall dependence.