← learnModule 9 · Risk aggregation6 min read

Aggregating a portfolio, correctly: sensitivities add, risk doesn't

Four trades, two names, opposite signs on one of them. Summing their adjoint Greeks into a book number is exact, ordinary addition — no kernel involved. Summing those numbers into one risk figure per bucket is a different, nonlinear operation, and skipping the correlation between risk factors overstates the risk by 40% on this page's own book.

Every trade in a book gets recorded and risked on its own tape — nothing in this page touches a kernel. The question is what happens after: is "the book's delta to ACME" just the sum of every trade's delta to ACME? Yes, exactly, and that's the easy half. Is "the book's risk in bucket 5" just the sum of the magnitudes of every risk factor in it? No — and how much that overstates the real number is this page's real finding.

The whole story

Each trade is priced and risked on its own tape, keyed by a RiskFactor(bucket, name). Portfolio.aggregate() and byNettingSet() are plain addition of Sensitivities vectors, never touching a kernel: a netting-set split reconstructs the book exactly, and the book's aggregate adjoint delta to each name matches a full one-factor-at-a-time bump grid across the whole mixed book to within 0.05%. NestedAggregation then turns a bucket's weighted sensitivities into one risk number, K_b = sqrt(sum of squares plus within-bucket correlation cross-terms), and combines buckets the same way with a second correlation matrix. On a real two-bucket book (bucket 5: weighted sensitivities 30 and -12, correlation 0.25; bucket 6: weighted sensitivity 17.5; across-bucket correlation 0.15) the correlated total is 35.563324, a 40.23% diversification credit against the naive sum of magnitudes, 59.5.

Did you know?

Portfolio and Sensitivities both say the same thing in their own class docs: composition here is "plain addition... entirely outside the tape." That's a stronger claim than it sounds — every risk number in Module 9 so far (VaR, a backtest, a scenario ladder) still needed the tape or its Greeks somewhere in the loop. This is the one piece of the risk stack that provably doesn't: once a trade's Sensitivities exist, combining a thousand of them into a book, or a netting set, is nothing more than summing plain doubles keyed by RiskFactor.

One trade, one tape, one key

Each position prices and risks itself independently, then tags its Greeks with a RiskFactor:

Sensitivities adjointSensitivities() {
  try (MonteCarlo<EquityMarket> mc = MonteCarlo.of(Products.europeanCall())
      .market(market).steps(STEPS).greeks().on("cpu-jit").build()) {
    var p = mc.run(N, SEED);
    return Sensitivities.builder()
        .add(RiskFactor.equityDelta(bucket, name), weight * p.greek(EquityMarket::spot))
        .add(RiskFactor.equityVega(bucket, name, 1.0), weight * p.greek(EquityMarket::vol))
        .build();
  }
}

RiskFactor(riskClass, measure, bucket, name, tenor, tenor2) is one record shape shared by every FRTB SA / SIMM risk class — GIRR, credit spread, equity, commodity, FX all reuse it, with the meaning of name and the two tenors fixed by convention per class rather than by a different type each time. This page only ever constructs equityDelta/equityVega factors; the other conventions are Module 10's business.

Portfolio and Sensitivities: plain addition

A four-trade book: a long and a short in the same name (ACME, bucket 5, netting set NS_A), and two single positions in a different bucket (GLOBEX and INITECH, bucket 6, netting set NS_B).

Portfolio portfolio = new Portfolio(book.stream()
    .map(p -> Portfolio.trade(p.id(), p.nettingSet(), p.adjointSensitivities()))
    .toList());
Sensitivities agg = portfolio.aggregate();          // sum over every trade
Map<String, Sensitivities> byNs = portfolio.byNettingSet();  // sum per netting set

Ran this exact book (PortfolioAggregationTest's own four positions, weights 1.0/-0.5/2.0/1.0) for real. First check: does byNettingSet() split the book and add back to exactly what aggregate() computed directly? Every one of the six risk factors (delta and vega, three names) matched to 1e-9 — plain addition associates exactly the way it's supposed to. Second, harder check: does the book's aggregate adjoint delta to each name match a full one-factor-at-a-time bump of the entire mixed book (both ACME trades repriced together, long and short, for the ACME bump)?

namebook pricebump-grid deltaaggregate adjoint deltarel. error
ACME5.4653050.3451110.3449500.047%
GLOBEX15.2885931.3047981.3049270.010%
INITECH6.3592500.4961050.4960690.007%

Every trade recorded its own tape, in complete isolation from the other three — and their summed adjoint Greeks still land within 0.05% of what bumping the whole book, long and short positions together, would have found the slow way.

Buckets and correlation: NestedAggregation

Delta-per-name is still a vector. Turning a whole bucket's vector into one risk number is where addition stops being enough — NestedAggregation weights each factor, then combines it with every other factor in its bucket through a correlation matrix, not a sum:

K_b   = sqrt(max(0, sum_k WS_k^2 + sum_{k!=l} rho_kl * WS_k * WS_l))
S_b   = clamp(sum_k WS_k, -K_b, K_b)
total = sqrt(max(0, sum_b K_b^2 + sum_{b!=c} gamma_bc * S_b * S_c))

Ran NestedAggregationTest's own hand-worked book directly: bucket 5 holds two weighted sensitivities, 30 and -12 (risk weight 0.30 on raw sensitivities 100 and -40), correlated at rho = 0.25; bucket 6 holds one, 17.5; the two buckets combine at gamma = 0.15.

quantityvalue
K_5 (bucket 5 risk number)29.393877
S_5 (bucket 5 signed total, clamped)18.000000
K_6 = S_6 (bucket 6, one factor)17.500000
total35.563324
naive sum of |weighted sensitivities|59.500000
diversification credit40.23%

That 40.23% is the number this page is really about: adding up the magnitude of every risk factor, with no bucket structure and no correlation, overstates this book's real risk by two-fifths. The correlation matrix isn't a regulatory nicety bolted on top of the real number — it's the difference between 59.5 and 35.56.

Did you know?

The textbook diversification story says lower correlation between risk factors always means more diversification credit. NestedAggregation's own cross term, rho_kl · WS_k · WS_l, inverts that whenever two factors in the same bucket have opposite signs — bucket 5's 30 and -12 are exactly that case. A positive correlation multiplying a negative product (30 × -12) subtracts from the sum under the square root, so more correlation between an offsetting long and short shrinks K_b, and less correlation grows it. The intuition only holds for same-signed exposures; for a hedge, it runs backwards.

Try it yourself

Drop bucket 5's within-bucket correlation from 0.25 to 0 — one line, (k, l) -> k.equals(l) ? 1.0 : 0.0 instead of the bucket-aware lambda — and rerun. K_5 rises, from 29.393877 to 32.310989, and the book total rises from 35.563324 to 38.009867. Treating ACME's long and short as independent instead of correlated removes the very offset that was shrinking the bucket's risk number, exactly as the sidenote above predicts.

▶️ Run it

mvn -o -q -pl nablatensor-risk test -P mc -Dtest=PortfolioAggregationTest,NestedAggregationTest

PortfolioAggregationTest is tagged mc (Monte-Carlo, excluded from a plain mvn test) because it builds and runs four real kernels; NestedAggregationTest is plain arithmetic and runs either way. Both passed when run for this page.

⚠️ What this doesn't do

NestedAggregation supports withConcentration(...) for SIMM's concentration-risk scaling and a squared-correlation, sign-gated variant for curvature — neither appears on this page, and CorrelationScenario's regulatory LOW/MEDIUM/HIGH correlation stress (take the worst of three capital numbers) doesn't either. Those are FRTB SA / SIMM specifics, Module 10's job, not this one's. This page's book is also a toy: two buckets, three names — real SA-SBM buckets FRTB and SIMM commonly hold dozens of names each, though the arithmetic doesn't change shape at all going from three factors to three hundred.

What's next

→ Deeper: Portfolio aggregation covers the same classes plus TimeProfile, the XVA exposure-profile hook this page didn't need. → Module 9 (Risk aggregation) is complete. Next: FRTB curvature, explained simply — Module 10.1 is the direct scale-up of the one page that already proves this whole format works, using the exact NestedAggregation curvature variant this page's "what this doesn't do" named but didn't run.


Questions or corrections? open an issue