How a portfolio works¶
A qs.Portfolio holds an ordered collection of positions and combines their
valuation results. This walkthrough builds a small USD book, prices it, and
checks its PV, risk and payments against individual positions. It also shows
what repeated trades, opposite directions and different requests mean.
The book contains a deposit, a receive-fixed swap and a smaller pay-fixed position in the same swap. The market is illustrative, as of 24 February 2026. All market inputs are defined here; no data files or downloads are needed. Run the cells in order with a binding built from this checkout and the example dependencies.
Computational study. Canonical notebook · Companion files and setup. Download this notebook.
This page renders saved outputs; the documentation build does not execute it. Run the canonical notebook from its own directory with the checkout-built binding to reproduce the computation. Each study records its market inputs and numerical assumptions.
import pandas as pd
import quantsupport as qs
from IPython.display import display
VALUATION_DATE = "2026-02-24"
START = "2026-03-02"
REQUESTS = [qs.Request.Value, qs.Request.Sensitivities, qs.Request.Cashflows]
1. Instruments describe contracts; trades describe positions¶
The deposit specifies principal, coupon and maturity. The swap specifies the
fixed and floating legs. Each Trade adds an execution date, side and optional
size override:
A
LongReceivedeposit advances principal, then receives interest and redemption.A
LongReceiveswap receives fixed and pays floating.A
PayShortswap pays fixed and receives floating. Use the side to reverse a position, and a non-negativenotionalto set its size.
Both swap positions below reference the same instrument. The second trade’s 500,000 notional override leaves the instrument’s 1,000,000 contractual notional unchanged. We keep our own position labels because an instrument identifier need not be unique across positions.
deposit = qs.FixedRateDeposit(
identifier="USD-DEPOSIT", start_date=START, maturity_date="2027-03-02",
notional=100_000, rate=0.04, currency="USD", discount_index="SOFR",
)
swap = qs.Swap(
identifier="USD-SWAP", start_date=START, maturity_date="2028-03-02",
notional=1_000_000, fixed_rate=0.04, currency="USD", market_index="SOFR",
fixed_leg_frequency="Semiannual", floating_leg_frequency="Quarterly",
)
receive_fixed = qs.Trade(swap, VALUATION_DATE, qs.Side.LongReceive)
pay_half = qs.Trade(swap, VALUATION_DATE, qs.Side.PayShort, notional=500_000)
positions = {
"Deposit": qs.Trade(deposit, VALUATION_DATE, qs.Side.LongReceive),
"Receive fixed": receive_fixed,
"Pay fixed, half size": pay_half,
}
portfolio = qs.Portfolio("USD-BOOK", list(positions.values()))
print(portfolio)
pd.DataFrame([
{
"position": label,
"instrument": trade.instrument.identifier,
"side": str(trade.side),
"notional_usd": (
trade.notional if trade.notional is not None else trade.instrument.notional
),
}
for label, trade in positions.items()
])
Portfolio(identifier="USD-BOOK", trades=3)
| position | instrument | side | notional_usd | |
|---|---|---|---|---|
| 0 | Deposit | USD-DEPOSIT | LongReceive | 100000.0 |
| 1 | Receive fixed | USD-SWAP | LongReceive | 1000000.0 |
| 2 | Pay fixed, half size | USD-SWAP | PayShort | 500000.0 |
2. Give every member a common pricing context¶
A portfolio holds trade references; the PricingContext supplies market inputs,
discount policies and pricers. Here a SOFR curve projects floating payments and
discounts all USD payments. Its continuously compounded zero rates are annual
decimals: 0.03 is 3%. All accruals start after valuation, so historical fixings
are unnecessary.
The helper creates a fresh context for each calculation. Entering with context:
validates inputs and lets requests construct their market dependencies. Exit
releases the constructed market and differentiation scope. Keep these contexts
sequential. Numeric EvaluationResults remain usable after exit.
PILLARS = ["0D", "1Y", "5Y"]
ZERO_RATES = [0.03, 0.032, 0.035]
def make_context(rate_shift=0.0, include_eur=False):
inputs = [("USD", "SOFR", PILLARS, [rate + rate_shift for rate in ZERO_RATES])]
if include_eur:
inputs.append(("EUR", "ESTR", ["0D", "5Y"], [0.03, 0.03]))
quotes = qs.QuoteStore.from_dict({
"reference_date": VALUATION_DATE,
"quotes": [
{"identifier": f"ZeroRate_{currency}_{index}_{pillar}", "mid": rate}
for currency, index, pillars, rates in inputs
for pillar, rate in zip(pillars, rates)
],
})
curves = [qs.CurveConfiguration(
market_index=index, day_counter="Actual360", interpolator="Linear",
quotes=[f"ZeroRate_{currency}_{index}_{pillar}" for pillar in pillars],
) for currency, index, pillars, _ in inputs]
return qs.PricingContext(
quotes=quotes, curves=curves,
csa_policy=qs.SingleCurveCSADiscountPolicy("USD", "SOFR"),
)
pd.DataFrame({"pillar": PILLARS, "zero_rate": ZERO_RATES})
| pillar | zero_rate | |
|---|---|---|
| 0 | 0D | 0.030 |
| 1 | 1Y | 0.032 |
| 2 | 5Y | 0.035 |
3. Evaluate the portfolio¶
context.evaluate(portfolio, requests) returns the same
EvaluationResults type as evaluating one trade. Omitting requests requests
only Value. Here we also ask for sensitivities and cashflows.
Native Rust pricers use the context policies and FX store to report monetary PVs and sensitivities in the context base currency. They apply FX during valuation, so sensitivities include the conversion. Here the context uses USD collateral, so monetary results are in USD. Cashflows retain their own payment currencies.
Portfolio stores the ordered positions. The context selects the native Rust
PortfolioPricer, which implements the standard Pricer interface for Value,
Sensitivities and Cashflows. It delegates each member to its own pricer and
combines the completed numeric results.
The calculation proceeds as follows:
Step |
What happens |
|---|---|
Read a member |
Preserve the list’s order and repeated entries |
Price the member |
Delegate to the member pricer using the context |
Combine results |
Add numeric PV, labelled derivatives and compatible cashflows in portfolio order |
Return |
Produce one standard result with the portfolio identifier, here |
Built-in pricers finish differentiation per trade. Portfolio aggregation keeps numeric totals without retaining a combined AD graph or a collection of member results. We evaluate members separately below solely to explain and reconcile the total.
with make_context() as context:
value_only = context.evaluate(portfolio)
result = context.evaluate(portfolio, REQUESTS)
individual = {
label: context.evaluate(trade, REQUESTS)
for label, trade in positions.items()
}
pd.DataFrame([{
"identifier": result.identifier,
"evaluation_date": str(result.evaluation_date),
"positions": len(portfolio),
"pv_usd": result.price,
}])
| identifier | evaluation_date | positions | pv_usd | |
|---|---|---|---|---|
| 0 | USD-BOOK | 2026-02-24 | 3 | 7506.367672 |
4. Reconcile PV and position sizes¶
Portfolio PV is the sum of signed member PVs in their common pricing currency:
The half-size pay-fixed position contributes minus half the receive-fixed position’s value. Signs and size are already applied by each trade’s pricer; do not apply them again to its result. Position labels are retained in our separate table, rather than in the aggregate result.
position_values = pd.DataFrame([
{"position": label, "pv_usd": item.price}
for label, item in individual.items()
]).set_index("position")
display(position_values)
print(f"Portfolio PV: USD {result.price:,.2f}")
| pv_usd | |
|---|---|
| position | |
| Deposit | 728.763613 |
| Receive fixed | 13555.208117 |
| Pay fixed, half size | -6777.604058 |
Portfolio PV: USD 7,506.37
5. Add sensitivities by their input label¶
For a shared input \(q_k\), aggregate risk is
\(\partial V_{\mathrm{portfolio}}/\partial q_k =
\sum_i \partial V_i/\partial q_k\). Match the pillar labels when adding
risks; the table’s row order is not an input identity.
result.sensitivities contains pillar and value columns. Each value is a
raw derivative per unit input. This market uses direct decimal zero-rate
pillars, so multiplying by 1e-4 estimates USD P&L for a +1 bp move. A parallel
+1 bp move is the sum of those scaled derivatives. Volatility, FX and spread
inputs in other markets need their own units and scaling.
trade_risk = pd.concat([
item.sensitivities.assign(position=label)
for label, item in individual.items()
], ignore_index=True)
expected_risk = trade_risk.groupby("pillar")["value"].sum().sort_index()
actual_risk = result.sensitivities.set_index("pillar")["value"].sort_index()
risk_report = actual_risk.rename("dPV_dZeroRate").to_frame()
risk_report["pnl_plus_1bp_usd"] = actual_risk * 1e-4
risk_report
| dPV_dZeroRate | pnl_plus_1bp_usd | |
|---|---|---|
| pillar | ||
| ZeroRate_USD_SOFR_0D | 7286.052758 | 0.728605 |
| ZeroRate_USD_SOFR_1Y | -856863.863478 | -85.686386 |
| ZeroRate_USD_SOFR_5Y | -251069.066639 | -25.106907 |
Check risk with full revaluation¶
Shift all three zero-rate pillars by +1 bp and -1 bp in fresh contexts. The central difference \([V(q+h)-V(q-h)]/(2h)\) should agree with the summed raw derivatives. The one-sided P&L differs slightly from the linear estimate because PV is nonlinear in rates.
bump = 1e-4
with make_context(bump) as context:
plus = context.evaluate(portfolio).price
with make_context(-bump) as context:
minus = context.evaluate(portfolio).price
central_derivative = (plus - minus) / (2 * bump)
aad_derivative = actual_risk.sum()
pd.DataFrame([{
"summed_derivative_usd_per_unit": aad_derivative,
"central_difference_usd_per_unit": central_derivative,
"linear_pnl_plus_1bp_usd": aad_derivative * bump,
"revalued_pnl_plus_1bp_usd": plus - result.price,
}])
| summed_derivative_usd_per_unit | central_difference_usd_per_unit | linear_pnl_plus_1bp_usd | revalued_pnl_plus_1bp_usd | |
|---|---|---|---|---|
| 0 | -1.100647e+06 | -1.100647e+06 | -110.064688 | -110.053977 |
6. Read coupons and payment totals¶
The result provides three related pandas views:
Property |
Contents |
|---|---|
|
Signed, undiscounted payments, with dates, types, currencies and leg indices |
|
Payment rows enriched with contractual terms such as signed notional, accrual dates, rate/spread and fixing index |
|
Net |
Compatible coupons can be compressed, with compatible fixed rates and linear floating spreads weighted by notional. Coupon types, dates, conventions and fixing indices remain distinguishable. Opposite pay/receive directions remain separate in the detailed rows, even though the summary nets their amounts. The summary reports payments on payment dates; it does not allocate accrual NII.
Below, each swap coupon date retains both the full-size receive-fixed position and the half-size opposing position. Receipts are positive and payments negative.
coupons = result.coupons
coupon_rows = coupons["type"].isin(["FixedRateCoupon", "FloatingRateCoupon"])
coupons.loc[coupon_rows, [
"payment_date", "type", "accrual_start", "accrual_end", "fixing_index",
"notional", "rate", "spread", "amount", "currency",
]].head(8)
| payment_date | type | accrual_start | accrual_end | fixing_index | notional | rate | spread | amount | currency | |
|---|---|---|---|---|---|---|---|---|---|---|
| 4 | 2026-06-02 | FloatingRateCoupon | 2026-03-02 | 2026-06-02 | SOFR | -1000000.0 | NaN | 0.0 | -7842.893950 | USD |
| 5 | 2026-06-02 | FloatingRateCoupon | 2026-03-02 | 2026-06-02 | SOFR | 500000.0 | NaN | 0.0 | 3921.446975 | USD |
| 6 | 2026-09-02 | FixedRateCoupon | 2026-03-02 | 2026-09-02 | NaN | 1000000.0 | 0.04 | NaN | 20444.444444 | USD |
| 7 | 2026-09-02 | FixedRateCoupon | 2026-03-02 | 2026-09-02 | NaN | -500000.0 | 0.04 | NaN | -10222.222222 | USD |
| 8 | 2026-09-02 | FloatingRateCoupon | 2026-06-02 | 2026-09-02 | SOFR | -1000000.0 | NaN | 0.0 | -8102.604188 | USD |
| 9 | 2026-09-02 | FloatingRateCoupon | 2026-06-02 | 2026-09-02 | SOFR | 500000.0 | NaN | 0.0 | 4051.302094 | USD |
| 10 | 2026-12-02 | FloatingRateCoupon | 2026-09-02 | 2026-12-02 | SOFR | -1000000.0 | NaN | 0.0 | -8269.714496 | USD |
| 11 | 2026-12-02 | FloatingRateCoupon | 2026-09-02 | 2026-12-02 | SOFR | 500000.0 | NaN | 0.0 | 4134.857248 | USD |
Reconcile payments and reconstruct PV¶
First compare net payments with the independent trade reports. Then discount those payments using the same SOFR curve as the pricers. This reconstruction works for this deterministic deposit/swap book; other products may need option, credit or other valuation adjustments.
Initial advances are retained. Here the future deposit advance contributes -100,000 of capital on 2 March 2026 and its redemption contributes +100,000 at maturity. The swaps’ opposing principal exchanges cancel in the net summary. Adding undiscounted totals alone would not reproduce PV.
payments = pd.concat([item.cashflows for item in individual.values()], ignore_index=True)
expected_payments = payments.groupby(["payment_date", "currency"])["amount"].sum()
summary = result.cashflow_summary
actual_payments = summary.set_index(["payment_date", "currency"])["total"]
with make_context() as context:
curve = context.curve(qs.MarketIndex.SOFR)
payment_values = summary.assign(
discount_factor=summary["payment_date"].map(curve.discount_factor)
)
payment_values["pv_usd"] = payment_values["total"] * payment_values["discount_factor"]
print(f"Discounted payments: USD {payment_values['pv_usd'].sum():,.2f}")
payment_values
Discounted payments: USD 7,506.37
| payment_date | currency | interest | capital | other | total | discount_factor | pv_usd | |
|---|---|---|---|---|---|---|---|---|
| 0 | 2026-03-02 | USD | 0.000000 | -100000.0 | 0.0 | -100000.000000 | 0.999500 | -99949.957731 |
| 1 | 2026-06-02 | USD | -3921.446975 | 0.0 | 0.0 | -3921.446975 | 0.991722 | -3888.983707 |
| 2 | 2026-09-02 | USD | 6170.920128 | 0.0 | 0.0 | 6170.920128 | 0.983751 | 6070.646796 |
| 3 | 2026-12-02 | USD | -4134.857248 | 0.0 | 0.0 | -4134.857248 | 0.975682 | -4034.306019 |
| 4 | 2027-03-02 | USD | 9907.556560 | 100000.0 | 0.0 | 109907.556560 | 0.967548 | 106340.811900 |
| 5 | 2027-06-02 | USD | -4229.734265 | 0.0 | 0.0 | -4229.734265 | 0.959431 | -4058.140271 |
| 6 | 2027-09-02 | USD | 5943.799770 | 0.0 | 0.0 | 5943.799770 | 0.951291 | 5654.285854 |
| 7 | 2027-12-02 | USD | -4279.619008 | 0.0 | 0.0 | -4279.619008 | 0.943218 | -4036.614592 |
| 8 | 2028-03-02 | USD | 5783.851939 | 0.0 | 0.0 | 5783.851939 | 0.935125 | 5408.625441 |
7. Membership preserves repeated positions¶
A portfolio is a nonempty ordered list, with no automatic deduplication by object or instrument identifier. Listing the same trade twice gives twice its exposure. An equal-size opposite trade cancels its signed PV; our original half-size opposite trade cancels only half.
Use explicit opposite trades to express offsets. Portfolio membership has no
separate weights parameter. For legal counterparty netting, collateral and
future exposure, use NettingSet and its CSA terms.
pay_full = qs.Trade(swap, VALUATION_DATE, qs.Side.PayShort)
doubled = qs.Portfolio("PORTFOLIO", [receive_fixed, receive_fixed])
offset = qs.Portfolio("PORTFOLIO", [receive_fixed, pay_full])
with make_context() as context:
doubled_value = context.evaluate(doubled).price
offset_value = context.evaluate(offset).price
single_value = individual["Receive fixed"].price
pd.DataFrame([
{"membership": "receive", "positions": 1, "pv_usd": single_value},
{"membership": "receive, receive", "positions": len(doubled), "pv_usd": doubled_value},
{"membership": "receive, equal-size pay", "positions": len(offset), "pv_usd": offset_value},
])
| membership | positions | pv_usd | |
|---|---|---|---|
| 0 | receive | 1 | 13555.208117 |
| 1 | receive, receive | 2 | 27110.416234 |
| 2 | receive, equal-size pay | 2 | 0.000000 |
8. Request only measures that can be aggregated¶
Only Value, Sensitivities and Cashflows are aggregated. Trade-specific
analytics such as FairRate, yield and duration must be requested on individual
trades. Unsupported aggregate requests are ignored. An empty request list
requests no measures; it differs from omitting requests, which requests PV.
An unrequested measure is None. If any member omits a requested aggregate
measure, that measure also remains None for the whole portfolio. It is not a
zero or a partial total. A pricing error aborts evaluation without returning a
partial portfolio result. Portfolio("EMPTY", []) is rejected at construction.
The same context.evaluate call handles PV, risk and cashflow-only requests.
The examples below check these request rules on the same book.
with make_context() as context:
flows_only = context.evaluate(portfolio, [qs.Request.Cashflows])
with_fair_rate = context.evaluate(
portfolio, [qs.Request.Value, qs.Request.FairRate(0)],
)
no_measures = context.evaluate(portfolio, [])
pd.DataFrame([
{
"requests": label,
"has_pv": item.price is not None,
"has_sensitivities": item.sensitivities is not None,
"has_cashflows": item.cashflows is not None,
"has_fair_rate": item.fair_rate is not None,
}
for label, item in [
("default (Value)", value_only),
("Value, Sensitivities, Cashflows", result),
("Cashflows", flows_only),
("Value, FairRate(0)", with_fair_rate),
("[]", no_measures),
]
])
| requests | has_pv | has_sensitivities | has_cashflows | has_fair_rate | |
|---|---|---|---|---|---|
| 0 | default (Value) | True | False | False | False |
| 1 | Value, Sensitivities, Cashflows | True | True | True | False |
| 2 | Cashflows | False | False | True | False |
| 3 | Value, FairRate(0) | True | False | False | False |
| 4 | [] | False | False | False | False |
9. Native pricers convert foreign positions¶
Add a EUR deposit, an ESTR curve and spot FX of 1.10 USD per EUR.
The same context.evaluate API returns the deposit’s PV and monetary
sensitivities in USD. A portfolio containing it and the original USD positions
needs no currency argument. The payment summary still separates EUR and USD.
Discounting the deposit’s EUR payments reconstructs its local PV. Multiplying
that PV by spot checks the USD result independently; the derivative with
respect to EUR/USD equals the local EUR PV.
eur_deposit = qs.Trade(qs.FixedRateDeposit(
identifier="EUR-DEPOSIT", start_date=START, maturity_date="2027-03-02",
notional=100_000, rate=0.04, currency="EUR", discount_index="ESTR",
), VALUATION_DATE)
mixed_portfolio = qs.Portfolio("PORTFOLIO", [*positions.values(), eur_deposit])
context = make_context(include_eur=True)
fx = qs.FxStore()
fx.add("EUR", "USD", 1.10)
context.fx = fx
with context:
foreign = context.evaluate(eur_deposit, REQUESTS)
mixed = context.evaluate(mixed_portfolio, REQUESTS)
eur_curve = context.curve("ESTR")
local_pv_eur = sum(
payment.amount * eur_curve.discount_factor(payment.payment_date)
for payment in foreign.cashflows.itertuples(index=False)
)
pd.DataFrame([
{"position": "Original USD book", "pv_usd": result.price},
{"position": "EUR deposit (converted automatically)", "pv_usd": foreign.price},
{"position": "Mixed portfolio", "pv_usd": mixed.price},
])
| position | pv_usd | |
|---|---|---|
| 0 | Original USD book | 7506.367672 |
| 1 | EUR deposit (converted automatically) | 1031.485198 |
| 2 | Mixed portfolio | 8537.852870 |
For weighted coupon examples, distinct fixing indices and optional CSV exports, continue with coupon and cashflow aggregation. For a mixed swap/bond/caplet book and separate rate/volatility scenarios, read portfolio risk. For counterparty agreements and future exposure, see netting and XVA.
The tables are ordinary pandas objects. For example,
result.sensitivities.to_csv("portfolio_risk.csv", index=False) exports raw
risk, and result.cashflow_summary.to_csv("payments.csv", index=False) exports
undiscounted payment totals. Keep their input units and currencies with the
exported reports.