Skip to content
All library documents

Daily SOFR Swap Valuation: Separate Overnight Roll from Market P&L

Article Quant Q&A · Author: david

Summary

The document describes an attempt to revalue a 10-year USD SOFR interest rate swap on consecutive dates using RatesLib curves. The author expects daily mark-to-market changes to relate to swap-rate movements, duration, and carry, but reports noisy results. An edit notes that the calculation behaves better when the prior overnight fixing is passed as a one-day pandas Series rather than as a float.

The answer recommends splitting daily NPV change into two stages: the transition from the prior close to the current open, which rolls the curve forward without market movement, and the current day’s market move. It illustrates the first stage by translating a curve and supplying the fixing as a dated Series; the translated-curve NPV matches the prior NPV rolled forward by the fixing. This example explains overnight carry mechanics, but it is a simplified illustration and does not diagnose every source of noise in the original 10-year valuation setup.

Key ideas

  • Daily swap P&L can be separated into an overnight date roll and the current day’s market move.
  • The overnight roll accounts for the new valuation date without introducing market movement.
  • The example supplies the overnight fixing as a dated one-day Series.
  • A translated curve’s NPV is shown matching the prior NPV rolled forward by the fixing rate.
  • The example does not explain all possible sources of noise in the original valuation.

Tags

Full text
# Calculating swap npv 1 day in the futures with rateslib


# Calculating swap npv 1 day in the futures with rateslib












I’m trying to evaluate a SOFR IRS at two different dates using RatesLib.

At date t, I build a curve using market data and use it to price an IRS.

At date t+1, I revalue the same swap using the updated market curve built from t+1 data — essentially computing the mark-to-market of the swap initiated at t.

My expectation is that, if this is done daily, the mark-to-market changes should approximately match the cumulative difference in swap rates multiplied by the duration, plus the carry. However, the results I obtain are quite noisy and significantly deviate from expectations.

Has anyone encountered a similar issue or found a possible explanation for this?

Here is what I am doing for a 10Y USD SOFR swap (curves is a dict containing my fitted curves):

```
    as_of_t_plus_one = add_tenor(as_of, '1D', 'MF', 'nyc')

  
    irs = IRS(
        effective=as_of,
        termination=add_tenor(as_of, '10Y', 'MF', 'nyc'),
        notional=10000,
        curves=curves[as_of],
        spec='usd_irs',
    )

    irs_t_plus_one = IRS(
        effective=as_of,
        termination=add_tenor(as_of, '10Y', 'MF', 'nyc'),
        notional=10000,
        fixed_rate=irs.rate(),
        leg2_fixings=fixing.loc[as_of].values[0],
        curves=curves[as_of_t_plus_one],
        spec='usd_irs',
    )
```

Now, I would expect that if I do that every day and cumulate all `irs_t_plus_one.npv()` to match 10Y swap rates times duration plus carry (modulo notional).

Edit: it seems it works better when I use leg2_fixings as a pd.Series of one day rather than passing a float

So we should not pass a float.

## Answer by Attack68 (score 1)

https://quant.stackexchange.com/a/85162

You should always consider swap daily NPV in two stages.

- First is T-1 close to T-0 open. This is transitioning yesterday's curves to account for today's new date, without assuming anything regarding market moves.

- Then you have T-0 open to T-0 close, which accounts for market moves on the day T-0.

If you measure the T-1 close to T-0 open with consistent data in rateslib you should not 'leak' any PnL.

```
curve_T_minus_1_close = Curve(
    nodes={
        dt(2025, 10, 29): 1.0,
        dt(2026, 10, 29): 0.95
    },
    calendar="nyc",
    convention="act360"
)
curve_T_zero_open = curve_T_minus_1_close.translate(dt(2025, 10, 30))

irs = IRS(
    dt(2025, 10, 29),
    "6m",
    spec="usd_irs",
    fixed_rate=2.5,
    notional=1e9,
)
fixing_rate = curve_T_minus_1_close.rate(dt(2025, 10, 29), "1b")
irs_with_fix = IRS(
    dt(2025, 10, 29),
    "6m",
    spec="usd_irs",
    fixed_rate=2.5,
    notional=1e9,
    leg2_fixings=Series(
        index=[dt(2025, 10, 29)],
        data=[fixing_rate],
    )
)

t_minus_1_npv = irs.npv(curves=curve_T_minus_1_close)
print(t_minus_1_npv)
print(irs_with_fix.npv(curves=curve_T_zero_open))
print(t_minus_1_npv*(1+fixing_rate/36000))
```

In the last lines you will find that the NPV as determined by the translated curve exactly matches the projected NPV rolled forward overnight by the rate of the fixing.

Shown in full with attribution under the source's licence. Licence: CC BY-SA 4.0 (Stack Exchange)

This summary was written by Stratmill's research agent from the original; it is not a copy of the source.