Replicating a SOFR Curve & Swap from Bloomberg’s SWPM#

At a point in time on Thu 17th Aug 2023 loading the SWPM function in Bloomberg presented the following default SOFR curve data:

SWPM SOFR Curve

We can replicate the input data for the Curve in a table as follows:

In [1]: data = DataFrame({
   ...:     "Term": ["1W", "2W", "3W", "1M", "2M", "3M", "4M", "5M", "6M", "7M", "8M", "9M", "10M", "11M", "12M", "18M", "2Y", "3Y", "4Y"],
   ...:     "Rate": [5.30111, 5.30424, 5.30657, 5.31100, 5.34800, 5.38025, 5.40915, 5.43078, 5.44235, 5.44950, 5.44878, 5.44100, 5.42730, 5.40747, 5.3839, 5.09195, 4.85785, 4.51845, 4.31705],
   ...: })
   ...: 

In [2]: data["Termination"] = [add_tenor(dt(2023, 8, 21), _, "F", "nyc") for _ in data["Term"]]

In [3]: with option_context("display.float_format", lambda x: '%.6f' % x):
   ...:     print(data)
   ...: 
   Term     Rate Termination
0    1W 5.301110  2023-08-28
1    2W 5.304240  2023-09-05
2    3W 5.306570  2023-09-11
3    1M 5.311000  2023-09-21
4    2M 5.348000  2023-10-23
5    3M 5.380250  2023-11-21
6    4M 5.409150  2023-12-21
7    5M 5.430780  2024-01-22
8    6M 5.442350  2024-02-21
9    7M 5.449500  2024-03-21
10   8M 5.448780  2024-04-22
11   9M 5.441000  2024-05-21
12  10M 5.427300  2024-06-21
13  11M 5.407470  2024-07-22
14  12M 5.383900  2024-08-21
15  18M 5.091950  2025-02-21
16   2Y 4.857850  2025-08-21
17   3Y 4.518450  2026-08-21
18   4Y 4.317050  2027-08-23

Bloomberg defaults to a “Step Forward (cont)” interpolation mode, this is effectively the same as “log_linear” in rateslib’s formulation for Curves. We will configure DF nodes dates to be on the termination date of the swaps:

In [4]: sofr = Curve(
   ...:     id="sofr",
   ...:     convention="Act360",
   ...:     calendar="nyc",
   ...:     modifier="MF",
   ...:     interpolation="log_linear",
   ...:     nodes={
   ...:         **{dt(2023, 8, 17): 1.0},  # <- this is today's DF,
   ...:         **{_: 1.0 for _ in data["Termination"]},
   ...:     }
   ...: )
   ...: 

Now we will calibrate the curve to the given swap market prices, using a global Solver, passing in the calibrating instruments and rates.

In [5]: sofr_args = dict(effective=dt(2023, 8, 21), spec="usd_irs", curves="sofr")

In [6]: solver = Solver(
   ...:     curves=[sofr],
   ...:     instruments=[IRS(termination=_, **sofr_args) for _ in data["Termination"]],
   ...:     s=data["Rate"],
   ...:     instrument_labels=data["Term"],
   ...:     id="us_rates",
   ...: )
   ...: 
SUCCESS: `func_tol` reached after 5 iterations (levenberg_marquardt), `f_val`: 3.116743265440467e-17, `time`: 0.0562s

In [7]: data["DF"] = [float(sofr[_]) for _ in data["Termination"]]

In [8]: with option_context("display.float_format", lambda x: '%.6f' % x):
   ...:     print(data)
   ...: 
   Term     Rate Termination       DF
0    1W 5.301110  2023-08-28 0.998382
1    2W 5.304240  2023-09-05 0.997208
2    3W 5.306570  2023-09-11 0.996327
3    1M 5.311000  2023-09-21 0.994862
4    2M 5.348000  2023-10-23 0.990145
5    3M 5.380250  2023-11-21 0.985856
6    4M 5.409150  2023-12-21 0.981421
7    5M 5.430780  2024-01-22 0.976721
8    6M 5.442350  2024-02-21 0.972364
9    7M 5.449500  2024-03-21 0.968194
10   8M 5.448780  2024-04-22 0.963676
11   9M 5.441000  2024-05-21 0.959670
12  10M 5.427300  2024-06-21 0.955477
13  11M 5.407470  2024-07-22 0.951395
14  12M 5.383900  2024-08-21 0.947546
15  18M 5.091950  2025-02-21 0.926160
16   2Y 4.857850  2025-08-21 0.907898
17   3Y 4.518450  2026-08-21 0.874241
18   4Y 4.317050  2027-08-23 0.842731

Notice that the DFs are the same as those in SWPM (at least to a visible 1e-6 tolerance).

Next we will create a swap in SWPM and also create the same swap in rateslib. The metrics that SWPM and rateslib generate for npv, delta (DV01), gamma and analytic delta (PV01) are the same to within a very small tolerance.

SWPM Swap Metrics
In [9]: irs = IRS(
   ...:     effective=dt(2023, 11, 21),
   ...:     termination=dt(2025, 2, 21),
   ...:     notional=-100e6,
   ...:     fixed_rate=5.40,
   ...:     curves="sofr",
   ...:     spec="usd_irs",
   ...: )
   ...: 

In [10]: irs.npv(solver=solver)
Out[10]: <Dual: 456622.098604, (sofr0, sofr1, sofr2, ...), [0.0, 0.0, 0.0, ...]>

In [11]: irs.delta(solver=solver).sum()
Out[11]: 
local_ccy  display_ccy
usd        usd           -11879.94
dtype: float64

In [12]: irs.gamma(solver=solver).sum().sum()
Out[12]: 3.176665278863451

In [13]: irs.analytic_delta(curve=sofr)
Out[13]: <Dual: -11896.008577, (sofr0, sofr1, sofr2, ...), [-0.0, -0.0, -0.0, ...]>

Finally we can double check the cashflows and cashflows_table of the swap.

SWPM Swap Cashflows
In [14]: irs.cashflows_table(solver=solver)
Out[14]: 
local_ccy            USD
collateral_ccy       NaN
payment                 
2024-02-23      -7613.74
2025-02-25     501239.07

In [15]: irs.cashflows(solver=solver)
Out[15]: 
               Type   Period  Ccy  Acc Start    Acc End    Payment Convention  DCF      Notional   DF Collateral  Rate  Spread    Cashflow         NPV  FX Rate     NPV Ccy
leg1 0  FixedPeriod     Stub  USD 2023-11-21 2024-02-21 2024-02-23     act360 0.26 -100000000.00 0.97       None  5.40     NaN  1380000.00  1341464.35     1.00  1341464.35
     1  FixedPeriod  Regular  USD 2024-02-21 2025-02-21 2025-02-25     act360 1.02 -100000000.00 0.93       None  5.40     NaN  5490000.00  5082380.28     1.00  5082380.28
leg2 0  FloatPeriod     Stub  USD 2023-11-21 2024-02-21 2024-02-23     act360 0.26  100000000.00 0.97       None  5.43    0.00 -1387613.74 -1348865.48     1.00 -1348865.48
     1  FloatPeriod  Regular  USD 2024-02-21 2025-02-21 2025-02-25     act360 1.02  100000000.00 0.93       None  4.91    0.00 -4988760.93 -4618357.05     1.00 -4618357.05