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
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)?
| name | book price | bump-grid delta | aggregate adjoint delta | rel. error |
|---|---|---|---|---|
| ACME | 5.465305 | 0.345111 | 0.344950 | 0.047% |
| GLOBEX | 15.288593 | 1.304798 | 1.304927 | 0.010% |
| INITECH | 6.359250 | 0.496105 | 0.496069 | 0.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.
| quantity | value |
|---|---|
| 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 |
| total | 35.563324 |
| naive sum of |weighted sensitivities| | 59.500000 |
| diversification credit | 40.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.
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.