Building a Risk Framework Including STIR Convexity Adjustments#
Common risk frameworks that I have experienced, used STIRFuture
prices to construct IR Curves at the short end. However, they typically do so by implying a
direct curve rate from the futures’ prices, adding a convexity adjustment as a parameter,
manually.
When portfolio managers want to know their delta sensitivity versus the convexity parameter itself,
they can simply add up the number of STIRFuture contracts they have and compare versus swaps. This
is trivial to do, but, it does not provide an automatic and complete risk sensitivity
framework, or Solver
: it requires those extra steps. What this
page demonstrates is how to create a Solver for the first 2-years of the
Curve including convexity instruments so that risk against IRS
(and/or any other relevant Instruments) can be actively calculated.
First we construct a Curve
that is used to calculate STIR Future rates.
In this framework instrument prices are given in rate terms (i.e. price = 100 - rate).
In [1]: curve_stir = Curve(
...: nodes={
...: dt(2023, 11, 2): 1.0,
...: dt(2024, 3, 20): 1.0,
...: dt(2024, 6, 19): 1.0,
...: dt(2024, 9, 18): 1.0,
...: dt(2024, 12, 18): 1.0,
...: dt(2025, 3, 19): 1.0,
...: dt(2025, 6, 18): 1.0,
...: dt(2025, 9, 17): 1.0,
...: dt(2025, 12, 17): 1.0,
...: },
...: calendar="nyc",
...: convention="act360",
...: id="stir",
...: )
...:
Next we define the instruments and construct the risk framework Solver. These Instruments are the next 8 quarterly IMM 3M SOFR futures as of 2nd November 2023.
In [2]: instruments_stir = [
...: STIRFuture(dt(2023, 12, 20), dt(2024, 3, 20), spec="sofr3mf", curves="stir"),
...: STIRFuture(dt(2024, 3, 20), dt(2024, 6, 19), spec="sofr3mf", curves="stir"),
...: STIRFuture(dt(2024, 6, 19), dt(2024, 9, 18), spec="sofr3mf", curves="stir"),
...: STIRFuture(dt(2024, 9, 18), dt(2024, 12, 18), spec="sofr3mf", curves="stir"),
...: STIRFuture(dt(2024, 12, 18), dt(2025, 3, 19), spec="sofr3mf", curves="stir"),
...: STIRFuture(dt(2025, 3, 19), dt(2025, 6, 18), spec="sofr3mf", curves="stir"),
...: STIRFuture(dt(2025, 6, 18), dt(2025, 9, 17), spec="sofr3mf", curves="stir"),
...: STIRFuture(dt(2025, 9, 17), dt(2025, 12, 17), spec="sofr3mf", curves="stir"),
...: ]
...:
In [3]: stir_solver = Solver(
...: curves=[curve_stir],
...: instruments=instruments_stir,
...: s=[4.0, 3.75, 3.5, 3.25, 3.15, 3.10, 3.08, 3.07],
...: instrument_labels=["z23", "h24", "m24", "u24", "z24", "h25", "m25", "u25"],
...: id="STIRF"
...: )
...:
SUCCESS: `func_tol` reached after 4 iterations (levenberg_marquardt), `f_val`: 8.135658413833101e-17, `time`: 0.0157s
This Solver calculates risk sensitivities against these SOFR future rates. It can be used
to risk SOFR futures directly or risk IRS
that have been
mapped specifically to use the “stir” curve. This is not entirely accurate because IRS should be
priced from a convexity adjusted IRS curve.
Consider below creating a long STIR Future position in 1000 lots (at $25 per lot per BP) and analysing the delta risk sensitivity.
In [4]: stirf = STIRFuture(dt(2024, 9, 18), dt(2024, 12, 18), spec="sofr3mf", curves="stir", contracts=1000)
In [5]: stirf.delta(solver=stir_solver)
Out[5]:
local_ccy usd
display_ccy usd
type solver label
instruments STIRF z23 -0.00
h24 -0.00
m24 -0.00
u24 -25000.00
z24 -0.00
h25 -0.00
m25 -0.00
u25 -0.00
Next consider paying an IRS as measured over the same dates in an equivalent contract notional of 1bn USD.
In [6]: irs = IRS(dt(2024, 9, 18), dt(2024, 12, 18), spec="usd_irs", curves="irs", notional=1e9)
In [7]: irs.delta(curves="stir", solver=stir_solver)
Out[7]:
local_ccy usd
display_ccy usd
type solver label
instruments STIRF z23 0.00
h24 0.00
m24 0.00
u24 24238.76
z24 0.00
h25 0.00
m25 0.00
u25 0.00
Adding convexity adjustments#
Now that we have a Curve which defines STIR Future prices we can use a
Spread
to relate these prices to IRS prices and the
IRS Curve (technically this Curve does not have to have the same structure as the
“stir” Curve but for for purposes of example it inherits it for simplicity’s sake).
In [8]: curve_irs = Curve(
...: nodes={
...: dt(2023, 11, 2): 1.0,
...: dt(2024, 3, 20): 1.0,
...: dt(2024, 6, 19): 1.0,
...: dt(2024, 9, 18): 1.0,
...: dt(2024, 12, 18): 1.0,
...: dt(2025, 3, 19): 1.0,
...: dt(2025, 6, 18): 1.0,
...: dt(2025, 9, 17): 1.0,
...: dt(2025, 12, 17): 1.0,
...: },
...: calendar="nyc",
...: convention="act360",
...: id="irs",
...: )
...:
The Instruments are set to be Spreads between the original STIR Futures and an IRS (or potentially an FRA) measured over the same dates.
In [9]: instruments_irs = [
...: Spread(instruments_stir[0], IRS(dt(2023, 12, 20), dt(2024, 3, 20), spec="usd_irs", curves="irs")),
...: Spread(instruments_stir[1], IRS(dt(2024, 3, 20), dt(2024, 6, 19), spec="usd_irs", curves="irs")),
...: Spread(instruments_stir[2], IRS(dt(2024, 6, 19), dt(2024, 9, 18), spec="usd_irs", curves="irs")),
...: Spread(instruments_stir[3], IRS(dt(2024, 9, 18), dt(2024, 12, 18), spec="usd_irs", curves="irs")),
...: Spread(instruments_stir[4], IRS(dt(2024, 12, 18), dt(2025, 3, 19), spec="usd_irs", curves="irs")),
...: Spread(instruments_stir[5], IRS(dt(2025, 3, 19), dt(2025, 6, 18), spec="usd_irs", curves="irs")),
...: Spread(instruments_stir[6], IRS(dt(2025, 6, 18), dt(2025, 9, 17), spec="usd_irs", curves="irs")),
...: Spread(instruments_stir[7], IRS(dt(2025, 9, 17), dt(2025, 12, 17), spec="usd_irs", curves="irs")),
...: ]
...:
Finally, we add these into a new dependent Solver (we do not have to create a
dependency chain of Solvers we could do this all simultaneously in a single Solver, but
it is better elucidated this way). The convexity adjustment rates are shown here beside the
s
argument. Expressed negatively according to market convention (IRS curve is below
the STIR futures curve).
In [10]: full_solver = Solver(
....: pre_solvers=[stir_solver],
....: curves=[curve_irs],
....: instruments=instruments_irs,
....: s=[-0.07, -0.25, -0.5, -0.95, -1.4, -1.8, -2.2, -2.6],
....: instrument_labels=["z23", "h24", "m24", "u24", "z24", "h25", "m25", "u25"],
....: id="Convexity",
....: )
....:
SUCCESS: `func_tol` reached after 3 iterations (levenberg_marquardt), `f_val`: 2.2335740210665174e-13, `time`: 0.0223s
Now we can re-risk the original instruments as part of the extended risk framework.
The STIRFuture does not have any convexity risk. Its risk is expressed relative to other STIRFutures so hedging a STIRFuture with the same STIRFuture is an exact hedge.
In [11]: stirf.delta(solver=full_solver)
Out[11]:
local_ccy usd
display_ccy usd
type solver label
instruments STIRF z23 -0.00
h24 -0.00
m24 -0.00
u24 -25000.00
z24 -0.00
h25 -0.00
m25 -0.00
u25 -0.00
Convexity z23 0.00
h24 0.00
m24 0.00
u24 0.00
z24 0.00
h25 0.00
m25 0.00
u25 0.00
The IRS has convexity risk. Hedging an IRS with a STIRFuture constructs a portfolio that has exposure to the movement of the convexity adjustment.
In [12]: irs.delta(solver=full_solver)
Out[12]:
local_ccy usd
display_ccy usd
type solver label
instruments STIRF z23 -0.00
h24 0.00
m24 0.00
u24 24239.88
z24 0.00
h25 -0.00
m25 0.00
u25 0.00
Convexity z23 0.00
h24 0.00
m24 0.00
u24 24239.88
z24 0.00
h25 -0.00
m25 -0.00
u25 0.00
We can even combine the instruments into a single Portfolio
and observe the combined risk analytics.
In [13]: pf = Portfolio([stirf, irs])
In [14]: pf.delta(solver=full_solver)
Out[14]:
local_ccy usd
display_ccy usd
type solver label
instruments STIRF z23 -0.00
h24 -0.00
m24 0.00
u24 -760.12
z24 0.00
h25 -0.00
m25 -0.00
u25 0.00
Convexity z23 0.00
h24 0.00
m24 0.00
u24 24239.88
z24 0.00
h25 -0.00
m25 -0.00
u25 0.00
Sense checking the numbers#
Futures are generally oversold relative to swaps. The STIR Curve is higher than the IRS Curve.
The Portfolio constructed has bought STIR Futures and paid IRS, at a negative spread and thus has positive value as time passes (positive theta). The precise notional of the IRS should be larger if it were to precisely hedge the delta risk of the 1000 lots of the STIR Future. If the market moves and the convexity adjustments move higher (closer towards zero), this portfolio will make MTM gains.
To account for the gain over time (theta value) the Portfolio suffers from negative gamma. If volatility is less than expected over time this will be advantageous. If the volatility is higher and the market movement is significant the loss from gamma will be significant and outweight the value offered by the convexity adjustment.
In [15]: pf.gamma(solver=full_solver)
Out[15]:
type instruments
solver STIRF Convexity
label z23 h24 m24 u24 z24 h25 m25 u25 z23 h24 m24 u24 z24 h25 m25 u25
local_ccy display_ccy type solver label
usd usd instruments STIRF z23 0.00 0.00 0.00 -0.93 -0.00 0.00 0.00 -0.00 0.00 0.00 0.00 -0.93 0.00 0.00 0.00 0.00
h24 0.00 0.00 0.00 -0.61 -0.00 0.00 0.00 -0.00 0.00 0.00 0.00 -0.61 0.00 0.00 0.00 0.00
m24 0.00 0.00 0.00 -0.60 -0.00 0.00 0.00 -0.00 0.00 0.00 0.00 -0.60 0.00 0.00 0.00 0.00
u24 -0.93 -0.61 -0.60 -1.22 -0.01 0.00 0.00 -0.00 -0.93 -0.61 -0.60 -1.22 -0.01 0.00 0.00 0.00
z24 -0.00 -0.00 -0.00 -0.01 0.00 0.00 0.00 0.00 -0.00 -0.00 -0.00 -0.01 0.00 0.00 0.00 0.00
h25 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
m25 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
u25 -0.00 -0.00 -0.00 -0.00 0.00 0.00 0.00 0.00 -0.00 -0.00 -0.00 -0.00 0.00 0.00 0.00 0.00
Convexity z23 0.00 0.00 0.00 -0.93 -0.00 0.00 0.00 -0.00 0.00 0.00 0.00 -0.93 0.00 0.00 0.00 0.00
h24 0.00 0.00 0.00 -0.61 -0.00 0.00 0.00 -0.00 0.00 0.00 0.00 -0.61 0.00 0.00 0.00 0.00
m24 0.00 0.00 0.00 -0.60 -0.00 0.00 0.00 -0.00 0.00 0.00 0.00 -0.60 0.00 0.00 0.00 0.00
u24 -0.93 -0.61 -0.60 -1.22 -0.01 0.00 0.00 -0.00 -0.93 -0.61 -0.60 -1.22 -0.01 0.00 0.00 0.00
z24 0.00 0.00 0.00 -0.01 0.00 0.00 0.00 0.00 0.00 0.00 0.00 -0.01 0.00 0.00 0.00 0.00
h25 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
m25 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00
u25 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00 0.00