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 LongReceive deposit advances principal, then receives interest and redemption.

  • A LongReceive swap receives fixed and pays floating.

  • A PayShort swap pays fixed and receives floating. Use the side to reverse a position, and a non-negative notional to 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 USD-BOOK

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:

\[V_{\mathrm{portfolio}} = \sum_i V_i.\]

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

cashflows

Signed, undiscounted payments, with dates, types, currencies and leg indices

coupons

Payment rows enriched with contractual terms such as signed notional, accrual dates, rate/spread and fixing index

cashflow_summary

Net interest, capital, other and total grouped by payment date and currency

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.