Strasmore Research
Deep Dives · Matt ConnorBy Matt Connor ·

How Value at Risk Is Calculated: 3 Methods

Historical, parametric, and Monte Carlo value at risk computed on one pinned SPY return series, plus the expected shortfall that VaR leaves out entirely.

Value at risk, or VaR, is calculated by fixing a horizon, fixing a confidence level, and then reading a percentile off a return series. A 1-day 99% VaR of 2% would mean the loss stays under 2% on 99 of every 100 sessions and exceeds it on the hundredth. Three standard methods answer that question from identical inputs, and they disagree by more than most people expect.

What value at risk actually measures

VaR is a quantile of a loss distribution. Sort every daily return in a sample from worst to best, walk 1% of the way in from the bad end, and the return you land on, written as a positive loss, is the 1-day 99% historical VaR. Nothing in that construction promises a worst case. It marks the edge of the region the estimate stops describing.

That is the property readers misplace most often. A 99% VaR states that the worst 1% of days lie past the threshold, and it is silent on how far past. Maximum drawdown answers a different question, the peak to trough loss a book actually lived through, and the two can rank the same pair of portfolios in opposite orders.

Every figure below rests on one series: SPY daily closes from the start of 2010 through the end of 2025, converted to close to close percent changes. The window is pinned rather than rolling, and each panel rebuilds the series from those same dates, so the numbers do not drift between runs.

QueryThe pinned return series: SPY daily returns by calendar year, 2010 through 2025
The exact SQL behind every number
WITH
px AS
(
    SELECT date, max(toFloat64(close)) AS close_px
    FROM global_markets.stocks_daily_aggs
    WHERE ticker = 'SPY'
      AND date >= '2009-12-01'
      AND date <  '2026-01-01'
    GROUP BY date
),
rets AS
(
    SELECT date, 100 * (close_px / prev_close - 1) AS ret_pct
    FROM
    (
        SELECT date, close_px,
               lagInFrame(close_px, 1) OVER (ORDER BY date ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS prev_close
        FROM px
    )
    WHERE date >= '2010-01-01' AND prev_close > 0
)
SELECT
    toString(toYear(date))        AS year,
    count()                       AS sessions,
    round(avg(ret_pct), 3)        AS mean_return_pct,
    round(stddevSamp(ret_pct), 2) AS daily_sigma_pct
FROM rets
GROUP BY year
ORDER BY year
Run this yourself

The series spans 16 calendar years, 252 sessions in 2010 alone. Two features of it matter for everything that follows. The mean session is close to irrelevant at this horizon: 0.054% in 2010, against a daily standard deviation, sigma, of 1.13%. And sigma is not a constant. 2020 ran at 2.11% a day, 2017 at 0.43%. One sigma cannot describe both.

How value at risk is calculated, three ways

Historical VaR: read the percentile off what happened

Sort the actual returns and take the percentile. No distribution is assumed, which is the appeal. What the method does assume is that the sample already holds the kind of day the estimate is meant to cover. Push the confidence level far enough out and only the worst few sessions in the whole window carry the answer.

Parametric VaR: mean minus z times sigma

Summarise the series with its mean and its sigma, then assume returns follow a normal distribution. VaR is z times sigma minus the mean, where z is the standard normal quantile: 1.645 at 95%, 2.326 at 99%, 3.090 at 99.9%. The arithmetic is instant and the assumption fails in a specific direction. Daily equity returns bunch more tightly than a normal curve through the middle and reach much further at the extremes. Sigma here is the denominator that also carries the Sharpe ratio, and it brings the same blind spot to fat-tailed data.

Monte Carlo VaR: simulate, with the seed pinned

Draw a large synthetic sample from an assumed process and read the percentile off the draws. The panel below uses 40,000 standard normal draws built by the Box-Muller transform from a hash seeded uniform sequence, scaled to the series mean and sigma. The seed sits in the SQL, so the draws are identical on every rerun. Simulation buys flexibility, path dependence and multi-asset correlation, without buying realism: hand it a normal distribution and it hands back the parametric answer with sampling noise on top. Resampling the observed returns instead, the technique behind bootstrapped confidence intervals, keeps the real tail in play.

QueryOne series, three methods: 1-day VaR at six confidence levels
The exact SQL behind every number
WITH
px AS
(
    SELECT date, max(toFloat64(close)) AS close_px
    FROM global_markets.stocks_daily_aggs
    WHERE ticker = 'SPY'
      AND date >= '2009-12-01'
      AND date <  '2026-01-01'
    GROUP BY date
),
rets AS
(
    SELECT date, 100 * (close_px / prev_close - 1) AS ret_pct
    FROM
    (
        SELECT date, close_px,
               lagInFrame(close_px, 1) OVER (ORDER BY date ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS prev_close
        FROM px
    )
    WHERE date >= '2010-01-01' AND prev_close > 0
),
emp AS
(
    SELECT
        avg(ret_pct)                  AS mu,
        stddevSamp(ret_pct)           AS sd,
        quantileExact(0.100)(ret_pct) AS h90,
        quantileExact(0.050)(ret_pct) AS h95,
        quantileExact(0.025)(ret_pct) AS h975,
        quantileExact(0.010)(ret_pct) AS h99,
        quantileExact(0.005)(ret_pct) AS h995,
        quantileExact(0.001)(ret_pct) AS h999
    FROM rets
),
draws AS
(
    SELECT
        quantileExact(0.100)(z) AS z90,
        quantileExact(0.050)(z) AS z95,
        quantileExact(0.025)(z) AS z975,
        quantileExact(0.010)(z) AS z99,
        quantileExact(0.005)(z) AS z995,
        quantileExact(0.001)(z) AS z999
    FROM
    (
        SELECT sqrt(-2 * log(u1)) * cos(2 * pi() * u2) AS z
        FROM
        (
            SELECT
                (cityHash64('var-seed-u1', i) % 999999937 + 1) / 999999938.0 AS u1,
                (cityHash64('var-seed-u2', i) % 999999937 + 1) / 999999938.0 AS u2
            FROM (SELECT arrayJoin(range(40000)) AS i)
        )
    )
)
SELECT
    tupleElement(lvl, 1)                                                  AS confidence,
    round(-1 * tupleElement(lvl, 2), 2)                                   AS historical_var_pct,
    round(tupleElement(lvl, 3) * sd - mu, 2)                              AS parametric_var_pct,
    round(-1 * (mu + sd * tupleElement(lvl, 4)), 2)                       AS monte_carlo_var_pct,
    round(-1 * tupleElement(lvl, 2) - (tupleElement(lvl, 3) * sd - mu), 2) AS method_spread
FROM
(
    SELECT
        mu,
        sd,
        arrayJoin([
            ('90.0%', h90,  1.281552, z90,  1),
            ('95.0%', h95,  1.644854, z95,  2),
            ('97.5%', h975, 1.959964, z975, 3),
            ('99.0%', h99,  2.326348, z99,  4),
            ('99.5%', h995, 2.575829, z995, 5),
            ('99.9%', h999, 3.090232, z999, 6)
        ]) AS lvl
    FROM emp
    CROSS JOIN draws
)
ORDER BY tupleElement(lvl, 5)
Run this yourself

At the 95.0% level the three methods land within a point of each other: 1.66% historical, 1.74% parametric, 1.71% simulated. Out at 99.9% they part company: 5.85% historical against 3.31% parametric, a gap of 2.54 percentage points on identical data. The simulated column sits beside the parametric column at every level, which is the lesson rather than a defect. A simulation reproduces whatever distribution it was handed.

Read the panel down a column and each step in confidence widens the threshold. Read it across a row and the method choice barely registers through the middle of the distribution while it dominates the tail. A VaR limit quoted without its method and its window is not a number anyone else can reproduce.

What VaR does not tell you: expected shortfall

Expected shortfall, also called conditional VaR, averages the losses on the days that breach VaR. VaR marks where the tail begins. Expected shortfall measures what is inside it.

QueryVaR against expected shortfall, the average loss past the threshold
The exact SQL behind every number
WITH
px AS
(
    SELECT date, max(toFloat64(close)) AS close_px
    FROM global_markets.stocks_daily_aggs
    WHERE ticker = 'SPY'
      AND date >= '2009-12-01'
      AND date <  '2026-01-01'
    GROUP BY date
),
rets AS
(
    SELECT date, 100 * (close_px / prev_close - 1) AS ret_pct
    FROM
    (
        SELECT date, close_px,
               lagInFrame(close_px, 1) OVER (ORDER BY date ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS prev_close
        FROM px
    )
    WHERE date >= '2010-01-01' AND prev_close > 0
),
qs AS
(
    SELECT
        quantileExact(0.100)(ret_pct) AS q90,
        quantileExact(0.050)(ret_pct) AS q95,
        quantileExact(0.025)(ret_pct) AS q975,
        quantileExact(0.010)(ret_pct) AS q99,
        quantileExact(0.005)(ret_pct) AS q995,
        quantileExact(0.001)(ret_pct) AS q999
    FROM rets
),
tails AS
(
    SELECT
        any(q90)                        AS var90,
        any(q95)                        AS var95,
        any(q975)                       AS var975,
        any(q99)                        AS var99,
        any(q995)                       AS var995,
        any(q999)                       AS var999,
        avgIf(ret_pct, ret_pct <= q90)  AS es90,
        avgIf(ret_pct, ret_pct <= q95)  AS es95,
        avgIf(ret_pct, ret_pct <= q975) AS es975,
        avgIf(ret_pct, ret_pct <= q99)  AS es99,
        avgIf(ret_pct, ret_pct <= q995) AS es995,
        avgIf(ret_pct, ret_pct <= q999) AS es999
    FROM rets
    CROSS JOIN qs
    HAVING countIf(ret_pct <= q999) > 0
)
SELECT
    tupleElement(lvl, 1)                                  AS confidence,
    round(-1 * tupleElement(lvl, 2), 2)                   AS historical_var_pct,
    round(-1 * tupleElement(lvl, 3), 2)                   AS expected_shortfall_pct,
    round(tupleElement(lvl, 3) / tupleElement(lvl, 2), 2) AS es_to_var_ratio
FROM
(
    SELECT
        arrayJoin([
            ('90.0%', var90,  es90,  1),
            ('95.0%', var95,  es95,  2),
            ('97.5%', var975, es975, 3),
            ('99.0%', var99,  es99,  4),
            ('99.5%', var995, es995, 5),
            ('99.9%', var999, es999, 6)
        ]) AS lvl
    FROM tails
)
ORDER BY tupleElement(lvl, 4)
Run this yourself

At 99.0%, VaR on this series is 3.09% and expected shortfall is 4.43%, or 1.44 times the threshold. Under a normal distribution that ratio would sit near 1.15 at the same level. Even at 99.9%, where the threshold has already reached 5.85%, the average breach day comes in at 8.14%. A limit written on VaR alone treats every breach as the same event, and the ratio column measures how untrue that is.

Does 1-day 99% VaR mean the same thing to an institution and a trader?

No, and the mismatch is mostly horizon. A trader who is flat overnight holds risk for hours, so a one session threshold lines up with the holding period, though the intraday path can travel well past a close to close number. An institution funding long dated liabilities holds positions for years, its exposure lives over quarters, and its 1-day 99% figure works as a capital and monitoring artifact rather than a description of the risk it carries. Bank capital rules were built on a 1-day 99% VaR for years, and the Basel market risk framework later moved the measure to a 97.5% expected shortfall.

The standard bridge between horizons is scaling by the square root of time: multiply the one session figure by the square root of the horizon in sessions. That step assumes returns are independent with a constant sigma, and the year by year sigma column above already shows the second half of that failing.

QuerySquare root of time scaling against measured multi session losses, 99% level
The exact SQL behind every number
WITH
px AS
(
    SELECT date, max(toFloat64(close)) AS close_px
    FROM global_markets.stocks_daily_aggs
    WHERE ticker = 'SPY'
      AND date >= '2009-11-01'
      AND date <  '2026-01-01'
    GROUP BY date
),
multi AS
(
    SELECT
        date,
        100 * (close_px / p1  - 1) AS r1,
        100 * (close_px / p5  - 1) AS r5,
        100 * (close_px / p10 - 1) AS r10,
        100 * (close_px / p20 - 1) AS r20
    FROM
    (
        SELECT
            date,
            close_px,
            lagInFrame(close_px, 1)  OVER (ORDER BY date ROWS BETWEEN 20 PRECEDING AND CURRENT ROW) AS p1,
            lagInFrame(close_px, 5)  OVER (ORDER BY date ROWS BETWEEN 20 PRECEDING AND CURRENT ROW) AS p5,
            lagInFrame(close_px, 10) OVER (ORDER BY date ROWS BETWEEN 20 PRECEDING AND CURRENT ROW) AS p10,
            lagInFrame(close_px, 20) OVER (ORDER BY date ROWS BETWEEN 20 PRECEDING AND CURRENT ROW) AS p20
        FROM px
    )
    WHERE date >= '2010-01-01' AND p20 > 0
),
q AS
(
    SELECT
        quantileExact(0.01)(r1)  AS q1,
        quantileExact(0.01)(r5)  AS q5,
        quantileExact(0.01)(r10) AS q10,
        quantileExact(0.01)(r20) AS q20
    FROM multi
)
SELECT
    tupleElement(h, 1)                                             AS horizon,
    round(-1 * tupleElement(h, 3), 2)                              AS actual_var_pct,
    round(-1 * q1 * sqrt(tupleElement(h, 2)), 2)                   AS sqrt_scaled_var_pct,
    round(tupleElement(h, 3) / (q1 * sqrt(tupleElement(h, 2))), 2) AS actual_to_scaled_ratio
FROM
(
    SELECT
        q1,
        arrayJoin([
            ('1 session',   1.0,  q1,  1),
            ('5 sessions',  5.0,  q5,  2),
            ('10 sessions', 10.0, q10, 3),
            ('20 sessions', 20.0, q20, 4)
        ]) AS h
    FROM q
)
ORDER BY tupleElement(h, 4)
Run this yourself

The first row is an identity check: at 1 session the scaled figure equals the measured one, a ratio of 1. Out at 20 sessions the two separate, and not in the direction the shortcut is usually warned about: scaling the one session number gives 13.8% while the returns measured over that horizon print 10.7%, a ratio of 0.78. On this window the scaled figure sits above the measured multi session tail. Two patterns pull against each other here. A fat tailed single session distribution thins as returns are added together, and the aggregated 99% quantile then widens more slowly than the square root of the horizon. Volatility clustering runs the other way, stacking violent sessions inside one window. On this series the first pattern is the larger of the two, and neither direction is guaranteed in another window or another asset, which is the point: the multiplier is an assumption, not a measurement. That clustering is also the property volatility targeting leans on when it resizes positions.

One further limit, stated plainly. Everything above is single asset VaR on a broad index fund. A book of ten correlated names carries concentration risk that a portfolio VaR handles through a correlation estimate, and correlations move most at the moment the estimate matters.

Method notes and conventions
  • Percentiles come from an exact quantile rather than a sampled estimate, and the Monte Carlo draws come from a hash seeded uniform sequence written into the SQL, so every figure here recomputes to the same value.
  • Returns are close to close price changes with no dividend reinvestment, the usual convention for a one session VaR.
  • The horizon panel uses overlapping windows: consecutive 20 session returns share 19 days, so its tail rests on far fewer independent observations than the row count implies.

FAQ

What is a 1-day 99% VaR?

It is the loss level that the worst 1 in 100 trading days exceeds, measured over a single session. On the series above the historical estimate is 3.09%. The figure names a threshold and says nothing about the size of the losses beyond it.

Which VaR calculation method is most accurate?

None of the three is accurate in the abstract, since each answers the question under different assumptions. Historical VaR is faithful to the sample it is given and mute about anything the sample missed. Parametric VaR is cheap and understates equity tails. Monte Carlo is only as good as the distribution fed into it.

What is the difference between VaR and expected shortfall?

VaR is the threshold at a chosen confidence level. Expected shortfall averages the losses on the days that breach it. On this series expected shortfall at the 99% level runs 1.44 times VaR, against roughly 1.15 times under a normal distribution.

Can you convert a 1-day VaR into a 10-day VaR?

Multiplying by the square root of 10 is the standard shortcut, and it assumes returns are independent with constant volatility. The horizon panel measures the gap: over 20 sessions the scaled figure came in at 13.8% against a measured 10.7%.


Every panel above carries the SQL that produced it. To run the same three calculations on a different ticker or window, ask for it in plain English on the Strasmore terminal.

#risk#value at risk#expected shortfall#quant#position sizing