SOFR and RFR compounding
A compounded overnight rate is not an average of the fixings. It is a product of daily
accrual factors, each weighted by the number of calendar days that fixing applies for, on a
360-day year for SOFR and a 365-day year for SONIA — and then a lookback, lockout or observation
shift changes which fixings are in the window at all.
Every figure below is printed by scripts/sofr.py (runs in 0.3 s; QuantLib optional).
✅ Measured means this file produced it on 2026-09-09 with QuantLib 1.43, numpy 2.2.6,
Python 3.11.3. The worked coupon is 2026-04-01 → 2026-07-01 (62 business-day fixings, 91
calendar days), 100,000,000 notional, on a seeded synthetic SOFR path around 4.30% with
month-end spikes and a 25 bp policy step on 2026-06-18.
The rule: a compounded RFR is prod(1 + r_i × d_i / basis), weighted by CALENDAR days —
ACT/360 for SOFR and ESTR, ACT/365 for SONIA and TONA — and "lookback" names two different
windows until you say whether it shifts.
1. ✅ The NY Fed's own SOFR Index example, reproduced exactly
✅ Source-verified at the NY Fed, "Additional Information about Reference Rates Administered by
the New York Fed" (read 2026-09-09): the SOFR Index starts at 1.00000000 on 2018-04-02 and
compounds by (1 + SOFR × d/360) where d is the number of calendar days applicable.
| value date |
SOFR |
calendar days |
NY Fed index |
this file |
|diff| |
| 2018-04-02 |
1.80% |
1 |
1.00005000 |
1.00005000 |
0.0e+00 |
| 2018-04-03 |
1.83% |
1 |
1.00010084 |
1.00010084 |
0.0e+00 |
| 2018-04-04 |
1.74% |
1 |
1.00014917 |
1.00014917 |
0.0e+00 |
| 2018-04-05 |
1.75% |
1 |
1.00019779 |
1.00019779 |
0.0e+00 |
| 2018-04-06 (Fri) |
1.75% |
3 |
1.00034365 |
1.00034365 |
0.0e+00 |
🔑 The last row is the whole point: a Friday fixing carries three calendar days. ✅ The NY Fed
also states the weighting around a holiday: if the start date is a Wednesday and Thursday is a
holiday, that Wednesday's rate applies for 2/360, and the following Friday's for 3/360.
2. 🚨 Four ways to be a few basis points wrong
✅ Measured on the worked coupon:
| method |
rate |
vs correct |
per 100mm/quarter |
| compounded, day-weighted, ACT/360 (correct) |
4.352685% |
— |
— |
| simple day-weighted average instead of compounding |
4.329451% |
🚨 −2.32 bp |
−5,873 |
| compounded but with unweighted fixings |
4.341373% |
🚨 −1.13 bp |
−2,859 |
| plain mean of the fixings (no weights, no compounding) |
4.325484% |
🚨 −2.72 bp |
−6,876 |
| ACT/365 used consistently |
4.352366% |
−0.03 bp |
−81 |
| 🚨 ACT/360 accrual annualized on 365 |
4.413139% |
🚨 +6.05 bp |
🚨 +15,281 |
🔑 The basis appears TWICE — once inside every daily factor (1 + r·d/basis) and again in the
annualization × basis/D. ✅ Changed in both places it nearly cancels (−0.03 bp); changed in one
it is the full 365/360 (+6.05 bp). That is why "we use ACT/365, it is only 1.4%" is either
harmless or a 6 bp error depending on a line you did not look at.
🚨 Compounding vs simple averaging is not a rounding choice. ARRC and ISDA fallback
conventions compound; the gap is roughly r²·T/2 and grows with the square of the rate and the
length of the period. At 4.35% over a quarter it is 2.32 bp; it was under 0.01 bp in 2021.
3. 🚨 Lookback, lockout and observation shift are different windows
✅ Measured, 5-day conventions on the same coupon:
| convention |
fixings |
calendar days |
rate |
vs plain |
per 100mm |
| plain in-arrears |
62 |
91 |
4.352685% |
— |
— |
| 5-day lookback (rate shift) |
62 |
91 |
4.330251% |
🚨 −2.24 bp |
−5,671 |
| 5-day lookback + observation shift |
62 |
91 |
4.333248% |
🚨 −1.94 bp |
−4,913 |
| 5-day lockout |
62 |
91 |
4.348797% |
−0.39 bp |
−983 |
- 🚨 The two "5-day lookbacks" differ by 0.30 bp (758 per 100mm) and both are called a 5-day
lookback. They use the same rates; what differs is the day weights — a fixing that is a
Friday in the observation window can map to a Wednesday in the accrual window, and 3/360
becomes 1/360.
- 🚨 The lookback itself is the bigger number: −2.24 bp. It pushes the 25 bp policy step five
business days later inside the accrual period, so fewer days of the quarter see the higher rate.
- Lockout freezes the last
k fixings at the last observable one. It is the smallest of the
three here because it only affects the tail, but it turns the coupon into a step function of
one fixing.
- Payment delay does not change the rate at all — only when the cash moves — which is why it
is the one convention people get right.
⚠️ The magnitudes depend on where the rate moves sit relative to the window. The invariant is the
sign structure, not the size: a lookback pulls the window backwards in time, a lockout freezes
the tail, and an observation shift additionally re-weights.
4. ✅ The index is the only thing you need to store
✅ Measured on the same coupon:
|
rate |
| compounded from the 62 fixings |
4.3526852313% |
| from an unrounded index |
4.3526852313% (|diff| 0.0e+00) |
| from the published 8-dp index |
4.3526848352% (|diff| 4.0e-09 = 0.0000 bp) |
rate = (I_end / I_start − 1) × basis / days. Two index values and a day count reproduce any
tenor, which is why the NY Fed publishes an index at all. ⚠️ The NY Fed notes that averages
built from the rounded index may differ from the published averages in the fifth decimal place
— true, and 4.0e-09 here, i.e. below a hundredth of a basis point.
5. Five currencies, two bases
✅ Day counts read out of QuantLib 1.43's own index definitions (ql.Sofr(...).dayCounter()):
| rate |
ccy |
administrator |
basis |
QuantLib index day count |
rate on this path |
| SOFR |
USD |
NY Fed |
ACT/360 |
Actual/360 |
4.352685% |
| SONIA |
GBP |
Bank of England |
ACT/365 |
Actual/365 (Fixed) |
4.352366% |
| ESTR |
EUR |
ECB |
ACT/360 |
Actual/360 |
4.352685% |
| TONA |
JPY |
Bank of Japan |
ACT/365 |
Actual/365 (Fixed) |
4.352366% |
| SARON |
CHF |
SIX |
ACT/360 |
Actual/360 |
4.352685% |
🚨 The two index families do not even start at the same number. ✅ The SOFR Index is
1.00000000 from 2018-04-02 (NY Fed); ✅ the SONIA Compounded Index is 100.00000000 from
2018-04-23, is rounded to eight decimal places, carries the previous day's value internally at
18 decimals, and — unlike SOFR's — is published at 9am on the same London business day (Bank
of England, "SONIA key features and policies", read 2026-09-09). ✅ SONIA itself is a trimmed
mean rounded to four decimal places over the central 50% of the volume-weighted distribution;
✅ SOFR is a volume-weighted median rounded to the nearest basis point (NY Fed).
⚠️ QuantLib's UnitedStates::SOFR fixing calendar is not UnitedStates::GovernmentBond.
✅ Measured: they differ in 2026 by Good Friday, 2026-04-03 — SIFMA recommends a full close and
no SOFR is published. Adding a fixing on that date to ql.Sofr raises
At least one invalid fixing provided, which is the only place in this file where a convention
mismatch actually errors.
6. ✅ Checked against QuantLib on all four window conventions
|
QuantLib OvernightIndexedCoupon |
this file |
|diff| |
| compounded in arrears |
4.35268523% |
4.35268523% |
0.0000 bp |
| simple average |
4.32945055% |
4.32945055% |
0.0000 bp |
| 5-day lookback |
4.33025075% |
4.33025075% |
0.0000 bp |
| 5-day lookback + observation shift |
4.33324779% |
4.33324779% |
0.0000 bp |
The reference implementation reproduces QuantLib's lookbackDays and applyObservationShift
exactly, and QuantLib's RateAveraging.Compound vs RateAveraging.Simple reproduces §2's
+2.32 bp. Two independent implementations, one arithmetic.
7. What the script gives you
scripts/sofr.py — numpy only at import; QuantLib inside two functions.
| Function |
Does |
compound_factor(rates, weights, basis) / compounded_rate(...) |
§1, §2 — the index arithmetic |
calendar_day_weights(dates, period_end) |
the "calendar days applicable" per fixing |
simple_average_rate / unweighted_average_rate |
§2, the two wrong averages |
mixed_basis_rate(rates, weights, accrual_basis, annualization_basis) |
§2 — the basis used twice |
sofr_index_path(...) / rate_from_index(I0, I1, days, basis) |
§4 |
observation_window(start, end, holidays, lookback, lockout, observation_shift) |
§3 — fixing dates and weights |
rate_for_window(fixings, window, basis) |
apply a fixing history to a window |
synthetic_sofr(..., policy_date, policy_step) |
the seeded path, with spikes and a policy step |
quantlib_cross_checks(...) / quantlib_index_day_counts() |
§5, §6, or None |
NYFED_INDEX_EXAMPLE, SOFR_CALENDAR_HOLIDAYS_2026, RFR_CONVENTIONS |
the dated constants |
Where this sits
../libor-transition-and-fallbacks/SKILL.md — 🚨 which SOFR a fallback contract actually gets:
ISDA's compounded Fallback Rate for derivatives, CME Term SOFR for non-consumer cash, and the
five statutory spreads that are the same for both.
../ois-discounting-and-multi-curve/SKILL.md — turning these fixings and forwards into a
curve, and 🚨 why discounting at the projection curve leaves the par rate unchanged while
moving the annuity 1.461%.
../duration-convexity-and-dv01/SKILL.md — a floater's rate duration is the time to its next
reset; this skill defines what "reset" means.
../bond-conventions-and-accrued/SKILL.md — the ACT/360 vs ACT/365 argument on the bond side,
and 🚨 ACT/ACT ICMA without its schedule.
../../../fin-models/skills/term-structure-models/SKILL.md — compounding conventions on a zero
curve, where the same discount factor is six different rates.
../../../fin-core/skills/fundamental-and-macro-data/SKILL.md — FRED SOFR, SOFR30DAYAVG
and SOFRINDEX, and their revision behaviour.
../../../fin-libraries/skills/lib-quantlib/SKILL.md — addFixing, clearFixings and the
global fixing history that survives between objects.
1---2name: sofr-and-rfr-compounding3description: Compute a compounded-in-arrears overnight rate correctly - SOFR, SONIA, ESTR, TONA, SARON - including the lookback, lockout and observation-shift conventions. TRIGGER - SOFR compounded in arrears, SOFR Index, SOFR Averages, 30-day 90-day 180-day SOFR average, compounded RFR, daily compounding of an overnight rate, lookback, rate shift, observation shift, lockout, payment delay, "my SOFR coupon is a few basis points off", "compounded vs simple average SOFR", ACT/360 vs ACT/365 on SONIA, SONIA Compounded Index, ESTR, TONA, SARON, OvernightIndexedCoupon, RateAveraging.Compound, applyObservationShift, SOFRINDEX, SOFR30DAYAVG. SKIP for what a LIBOR contract falls back TO and the statutory spreads (libor-transition-and-fallbacks), for building an OIS discount curve (ois-discounting-and-multi-curve), for a floater's duration (duration-convexity-and-dv01), and for bond accrued interest and day counts (bond-conventions-and-accrued).4license: MIT5---67# SOFR and RFR compounding89**A compounded overnight rate is not an average of the fixings.** It is a product of daily10accrual factors, each weighted by the number of **calendar** days that fixing applies for, on a11360-day year for SOFR and a 365-day year for SONIA — and then a lookback, lockout or observation12shift changes which fixings are in the window at all.1314Every figure below is printed by `scripts/sofr.py` (runs in **0.3 s**; QuantLib optional).15✅ Measured means this file produced it on 2026-09-09 with QuantLib 1.43, numpy 2.2.6,16Python 3.11.3. The worked coupon is **2026-04-01 → 2026-07-01 (62 business-day fixings, 9117calendar days), 100,000,000 notional**, on a seeded synthetic SOFR path around 4.30% with18month-end spikes and a 25 bp policy step on 2026-06-18.1920> **The rule: a compounded RFR is `prod(1 + r_i × d_i / basis)`, weighted by CALENDAR days —21> ACT/360 for SOFR and ESTR, ACT/365 for SONIA and TONA — and "lookback" names two different22> windows until you say whether it shifts.**2324## 1. ✅ The NY Fed's own SOFR Index example, reproduced exactly2526✅ Source-verified at the **NY Fed, "Additional Information about Reference Rates Administered by27the New York Fed"** (read 2026-09-09): the SOFR Index starts at **1.00000000 on 2018-04-02** and28compounds by `(1 + SOFR × d/360)` where `d` is the number of **calendar days applicable**.2930| value date | SOFR | calendar days | NY Fed index | this file | \|diff\| |31|---|---|---|---|---|---|32| 2018-04-02 | 1.80% | 1 | 1.00005000 | 1.00005000 | **0.0e+00** |33| 2018-04-03 | 1.83% | 1 | 1.00010084 | 1.00010084 | **0.0e+00** |34| 2018-04-04 | 1.74% | 1 | 1.00014917 | 1.00014917 | **0.0e+00** |35| 2018-04-05 | 1.75% | 1 | 1.00019779 | 1.00019779 | **0.0e+00** |36| **2018-04-06 (Fri)** | 1.75% | **3** | 1.00034365 | 1.00034365 | **0.0e+00** |3738🔑 **The last row is the whole point: a Friday fixing carries three calendar days.** ✅ The NY Fed39also states the weighting around a holiday: if the start date is a Wednesday and Thursday is a40holiday, that Wednesday's rate applies for `2/360`, and the following Friday's for `3/360`.4142## 2. 🚨 Four ways to be a few basis points wrong4344✅ Measured on the worked coupon:4546| method | rate | vs correct | per 100mm/quarter |47|---|---|---|---|48| **compounded, day-weighted, ACT/360 (correct)** | **4.352685%** | — | — |49| simple day-weighted average instead of compounding | 4.329451% | 🚨 **−2.32 bp** | **−5,873** |50| compounded but with **unweighted** fixings | 4.341373% | 🚨 **−1.13 bp** | −2,859 |51| plain mean of the fixings (no weights, no compounding) | 4.325484% | 🚨 **−2.72 bp** | −6,876 |52| ACT/365 used **consistently** | 4.352366% | **−0.03 bp** | −81 |53| 🚨 **ACT/360 accrual annualized on 365** | **4.413139%** | 🚨 **+6.05 bp** | 🚨 **+15,281** |5455🔑 **The basis appears TWICE** — once inside every daily factor `(1 + r·d/basis)` and again in the56annualization `× basis/D`. ✅ **Changed in both places it nearly cancels (−0.03 bp); changed in one57it is the full 365/360 (+6.05 bp).** That is why "we use ACT/365, it is only 1.4%" is either58harmless or a 6 bp error depending on a line you did not look at.5960🚨 **Compounding vs simple averaging is not a rounding choice.** ARRC and ISDA fallback61conventions compound; the gap is roughly `r²·T/2` and grows with the square of the rate and the62length of the period. At 4.35% over a quarter it is 2.32 bp; it was under 0.01 bp in 2021.6364## 3. 🚨 Lookback, lockout and observation shift are different windows6566✅ Measured, 5-day conventions on the same coupon:6768| convention | fixings | calendar days | rate | vs plain | per 100mm |69|---|---|---|---|---|---|70| plain in-arrears | 62 | 91 | 4.352685% | — | — |71| **5-day lookback (rate shift)** | 62 | 91 | 4.330251% | 🚨 **−2.24 bp** | **−5,671** |72| **5-day lookback + observation shift** | 62 | 91 | 4.333248% | 🚨 **−1.94 bp** | −4,913 |73| 5-day lockout | 62 | 91 | 4.348797% | **−0.39 bp** | −983 |7475- 🚨 **The two "5-day lookbacks" differ by 0.30 bp (758 per 100mm) and both are called a 5-day76 lookback.** They use the *same rates*; what differs is the **day weights** — a fixing that is a77 Friday in the observation window can map to a Wednesday in the accrual window, and 3/36078 becomes 1/360.79- 🚨 **The lookback itself is the bigger number: −2.24 bp.** It pushes the 25 bp policy step five80 business days later inside the accrual period, so fewer days of the quarter see the higher rate.81- **Lockout** freezes the last `k` fixings at the last observable one. It is the smallest of the82 three here because it only affects the tail, but it turns the coupon into a step function of83 one fixing.84- **Payment delay** does not change the rate at all — only when the cash moves — which is why it85 is the one convention people get right.8687⚠️ The magnitudes depend on where the rate moves sit relative to the window. The invariant is the88*sign structure*, not the size: a lookback pulls the window backwards in time, a lockout freezes89the tail, and an observation shift additionally re-weights.9091## 4. ✅ The index is the only thing you need to store9293✅ Measured on the same coupon:9495| | rate |96|---|---|97| compounded from the 62 fixings | 4.3526852313% |98| from an **unrounded** index | 4.3526852313% (\|diff\| **0.0e+00**) |99| from the **published 8-dp** index | 4.3526848352% (\|diff\| 4.0e-09 = **0.0000 bp**) |100101`rate = (I_end / I_start − 1) × basis / days`. **Two index values and a day count reproduce any102tenor**, which is why the NY Fed publishes an index at all. ⚠️ The NY Fed notes that averages103built from the *rounded* index may differ from the published averages in the fifth decimal place104— true, and 4.0e-09 here, i.e. below a hundredth of a basis point.105106## 5. Five currencies, two bases107108✅ Day counts read out of QuantLib 1.43's own index definitions (`ql.Sofr(...).dayCounter()`):109110| rate | ccy | administrator | basis | QuantLib index day count | rate on this path |111|---|---|---|---|---|---|112| SOFR | USD | NY Fed | ACT/360 | Actual/360 | 4.352685% |113| **SONIA** | GBP | Bank of England | **ACT/365** | Actual/365 (Fixed) | 4.352366% |114| ESTR | EUR | ECB | ACT/360 | Actual/360 | 4.352685% |115| **TONA** | JPY | Bank of Japan | **ACT/365** | Actual/365 (Fixed) | 4.352366% |116| SARON | CHF | SIX | ACT/360 | Actual/360 | 4.352685% |117118🚨 **The two index families do not even start at the same number.** ✅ The SOFR Index is119**1.00000000 from 2018-04-02** (NY Fed); ✅ the **SONIA Compounded Index is 100.00000000 from1202018-04-23**, is rounded to eight decimal places, carries the previous day's value internally at12118 decimals, and — unlike SOFR's — **is published at 9am on the same London business day** (Bank122of England, "SONIA key features and policies", read 2026-09-09). ✅ SONIA itself is a **trimmed123mean rounded to four decimal places** over the central 50% of the volume-weighted distribution;124✅ SOFR is a **volume-weighted median rounded to the nearest basis point** (NY Fed).125126⚠️ **QuantLib's `UnitedStates::SOFR` fixing calendar is not `UnitedStates::GovernmentBond`.**127✅ Measured: they differ in 2026 by **Good Friday, 2026-04-03** — SIFMA recommends a full close and128no SOFR is published. Adding a fixing on that date to `ql.Sofr` raises129`At least one invalid fixing provided`, which is the only place in this file where a convention130mismatch actually errors.131132## 6. ✅ Checked against QuantLib on all four window conventions133134| | QuantLib `OvernightIndexedCoupon` | this file | \|diff\| |135|---|---|---|---|136| compounded in arrears | 4.35268523% | 4.35268523% | **0.0000 bp** |137| simple average | 4.32945055% | 4.32945055% | **0.0000 bp** |138| 5-day lookback | 4.33025075% | 4.33025075% | **0.0000 bp** |139| 5-day lookback + observation shift | 4.33324779% | 4.33324779% | **0.0000 bp** |140141The reference implementation reproduces QuantLib's `lookbackDays` and `applyObservationShift`142exactly, and QuantLib's `RateAveraging.Compound` vs `RateAveraging.Simple` reproduces §2's143+2.32 bp. Two independent implementations, one arithmetic.144145## 7. What the script gives you146147`scripts/sofr.py` — numpy only at import; QuantLib inside two functions.148149| Function | Does |150|---|---|151| `compound_factor(rates, weights, basis)` / `compounded_rate(...)` | §1, §2 — the index arithmetic |152| `calendar_day_weights(dates, period_end)` | the "calendar days applicable" per fixing |153| `simple_average_rate` / `unweighted_average_rate` | §2, the two wrong averages |154| `mixed_basis_rate(rates, weights, accrual_basis, annualization_basis)` | §2 — the basis used twice |155| `sofr_index_path(...)` / `rate_from_index(I0, I1, days, basis)` | §4 |156| `observation_window(start, end, holidays, lookback, lockout, observation_shift)` | §3 — fixing dates and weights |157| `rate_for_window(fixings, window, basis)` | apply a fixing history to a window |158| `synthetic_sofr(..., policy_date, policy_step)` | the seeded path, with spikes and a policy step |159| `quantlib_cross_checks(...)` / `quantlib_index_day_counts()` | §5, §6, or `None` |160| `NYFED_INDEX_EXAMPLE`, `SOFR_CALENDAR_HOLIDAYS_2026`, `RFR_CONVENTIONS` | the dated constants |161162## Where this sits163164- `../libor-transition-and-fallbacks/SKILL.md` — 🚨 which SOFR a fallback contract actually gets:165 ISDA's compounded Fallback Rate for derivatives, CME Term SOFR for non-consumer cash, and the166 five statutory spreads that are the same for both.167- `../ois-discounting-and-multi-curve/SKILL.md` — turning these fixings and forwards into a168 curve, and 🚨 why discounting at the projection curve leaves the par rate unchanged while169 moving the annuity 1.461%.170- `../duration-convexity-and-dv01/SKILL.md` — a floater's rate duration is the time to its next171 reset; this skill defines what "reset" means.172- `../bond-conventions-and-accrued/SKILL.md` — the ACT/360 vs ACT/365 argument on the bond side,173 and 🚨 ACT/ACT ICMA without its schedule.174- `../../../fin-models/skills/term-structure-models/SKILL.md` — compounding conventions on a zero175 curve, where the same discount factor is six different rates.176- `../../../fin-core/skills/fundamental-and-macro-data/SKILL.md` — FRED `SOFR`, `SOFR30DAYAVG`177 and `SOFRINDEX`, and their revision behaviour.178- `../../../fin-libraries/skills/lib-quantlib/SKILL.md` — `addFixing`, `clearFixings` and the179 global fixing history that survives between objects.