Skip to content

Commit f0f6bf1

Browse files
authored
DOC: ndirs cookbook and minor edits (#216) (#1134)
1 parent d92527a commit f0f6bf1

File tree

9 files changed

+325
-13
lines changed

9 files changed

+325
-13
lines changed
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
.. _cook-ndirs-doc:
2+
3+
.. ipython:: python
4+
:suppress:
5+
6+
from rateslib import dt, Solver, Curve, FXRates, FXForwards, IRS, NDXCS, defaults
7+
import matplotlib.pyplot as plt
8+
9+
10+
Non-Deliverable IRS and XCS: EM Markets
11+
*****************************************
12+
13+
*Rateslib* v2.5 introduced non-deliverable *IRS* and *XCS*. This page exemplifies how to use
14+
these objects to calibrate *Curves* in those markets. Specifically here we will use ND-IRS and
15+
NDXCS to calibrate Indian Rupee *Curves*.
16+
17+
**Key Points**
18+
19+
- A USD `Curve` is established in the normal way using US instruments.
20+
- An `FXForwards` market is proposed (uncalibrated) between USD and INR.
21+
- The suite of non-deliverable *Instruments* are constructed and used for calibration.
22+
23+
The Deliverable US Market
24+
-------------------------
25+
26+
First, we need to establish the baseline US market and the SOFR curve. This is no different to
27+
any other tutorial on the matter, so we quickly do this with some of the following SOFR swap data.
28+
Just for some variety, this *Curve* will be interpolated with a log-cubic DF spline.
29+
30+
.. ipython:: python
31+
32+
usd = Curve(
33+
nodes={
34+
dt(2025, 12, 29): 1.0,
35+
dt(2026, 12, 29): 1.0,
36+
dt(2027, 12, 29): 1.0,
37+
dt(2028, 12, 29): 1.0,
38+
dt(2029, 12, 29): 1.0,
39+
dt(2031, 1, 7): 1.0,
40+
},
41+
convention="Act360",
42+
calendar="nyc",
43+
interpolation="spline",
44+
id="sofr"
45+
)
46+
us_solver = Solver(
47+
curves=[usd],
48+
instruments=[
49+
IRS(dt(2025, 12, 31), "1y", spec="usd_irs", curves="sofr"),
50+
IRS(dt(2025, 12, 31), "2y", spec="usd_irs", curves="sofr"),
51+
IRS(dt(2025, 12, 31), "3y", spec="usd_irs", curves="sofr"),
52+
IRS(dt(2025, 12, 31), "4y", spec="usd_irs", curves="sofr"),
53+
IRS(dt(2025, 12, 31), "5y", spec="usd_irs", curves="sofr"),
54+
],
55+
s=[3.434, 3.302, 3.314, 3.359, 3.416],
56+
)
57+
58+
The Components for the ``FXForwards``
59+
----------------------------------------
60+
61+
We are going to use just the 1Y through 5Y instruments, as demonstration, to calibrate the
62+
INR market. So our local FBIL Overnight Mumbai Interbank Outright Rate (FBIL-O/N MIBOR) is the
63+
following:
64+
65+
.. ipython:: python
66+
67+
inr = Curve(
68+
nodes={
69+
dt(2025, 12, 29): 1.0,
70+
dt(2026, 12, 29): 1.0,
71+
dt(2027, 12, 29): 1.0,
72+
dt(2028, 12, 29): 1.0,
73+
dt(2029, 12, 29): 1.0,
74+
dt(2031, 1, 7): 1.0,
75+
},
76+
convention="Act365F",
77+
calendar="mum",
78+
id="mibor-ois",
79+
)
80+
81+
In order to introduce the necessary degrees of freedom to satisfy the cross-currency market and
82+
supply and demand we establish the basis curve:
83+
84+
.. ipython:: python
85+
86+
inrusd = Curve(
87+
nodes={
88+
dt(2025, 12, 29): 1.0,
89+
dt(2026, 12, 29): 1.0,
90+
dt(2027, 12, 29): 1.0,
91+
dt(2028, 12, 29): 1.0,
92+
dt(2029, 12, 29): 1.0,
93+
dt(2031, 1, 7): 1.0,
94+
},
95+
convention="Act365F",
96+
calendar="all", # <- no holiday calendar necessary for a cross-currency discount curve.
97+
id="inrusd",
98+
)
99+
100+
Finally we put all of the elements together to create the USDINR FXForwards market, note that
101+
we have also input the spot USDINR FX rate here as well:
102+
103+
.. ipython:: python
104+
105+
fxf = FXForwards(
106+
fx_rates=FXRates({"usdinr": 89.9812}, settlement=dt(2025, 12, 31)),
107+
fx_curves={"usdusd": usd, "inrinr": inr, "inrusd": inrusd},
108+
)
109+
110+
This object can now be used to forecast any USDINR rate, **but it won't be
111+
accurate** becuase we haven't calibrated anything yet! The INR rates are currently all zero on the
112+
2 INR *Curves*.
113+
114+
.. ipython:: python
115+
116+
fxf.rate("usdinr", settlement=dt(2026, 12, 31))
117+
fxf.swap("usdinr", [dt(2025, 12, 31), dt(2026, 12, 31)])
118+
119+
Calibrating the Curves
120+
-------------------------
121+
122+
So, we have now reached the point where we can calibrate the INR curves. We have 10 parameters /
123+
degrees of freedom and will therefore require 10 *Instruments*. We will use 5 *NDIRS*, which will
124+
calibrate local currency interest rates (the ``inr`` *Curve*) [Bloomberg Moniker IRSWNI1 Curncy],
125+
and 5 *NDXCS* [Bloomberg Moniker IRUSON1 Curncy] which will effectively calibrate the
126+
cross-currency basis.
127+
128+
*Rateslib* has added some ``spec`` defaults for the purpose of this article, but the keyword
129+
arguments used can be directly observed below:
130+
131+
.. ipython:: python
132+
133+
defaults.spec["inr_ndirs"]
134+
defaults.spec["inrusd_ndxcs"]
135+
136+
To calibrate we must include the previous US :class:`~rateslib.solver.Solver`, which contains
137+
the mapping to the constructed US SOFR *Curve*, and we specify the *Instruments* and the live
138+
market data rates.
139+
140+
.. ipython
141+
142+
143+
.. ipython:: python
144+
145+
inr_solver = Solver(
146+
pre_solvers=[us_solver],
147+
curves=[inr, inrusd],
148+
instruments=[
149+
IRS(dt(2025, 12, 30), "1Y", spec="inr_ndirs", curves=["mibor-ois", "sofr"]),
150+
IRS(dt(2025, 12, 30), "2Y", spec="inr_ndirs", curves=["mibor-ois", "sofr"]),
151+
IRS(dt(2025, 12, 30), "3Y", spec="inr_ndirs", curves=["mibor-ois", "sofr"]),
152+
IRS(dt(2025, 12, 30), "4Y", spec="inr_ndirs", curves=["mibor-ois", "sofr"]),
153+
IRS(dt(2025, 12, 30), "5Y", spec="inr_ndirs", curves=["mibor-ois", "sofr"]),
154+
NDXCS(dt(2025, 12, 31), "1Y", spec="inrusd_ndxcs", curves=[None, "sofr", "sofr", "sofr"]),
155+
NDXCS(dt(2025, 12, 31), "2Y", spec="inrusd_ndxcs", curves=[None, "sofr", "sofr", "sofr"]),
156+
NDXCS(dt(2025, 12, 31), "3Y", spec="inrusd_ndxcs", curves=[None, "sofr", "sofr", "sofr"]),
157+
NDXCS(dt(2025, 12, 31), "4Y", spec="inrusd_ndxcs", curves=[None, "sofr", "sofr", "sofr"]),
158+
NDXCS(dt(2025, 12, 31), "5Y", spec="inrusd_ndxcs", curves=[None, "sofr", "sofr", "sofr"]),
159+
],
160+
s=[
161+
5.47, 5.5525, 5.715, 5.835, 5.925, # <- IRS rates
162+
6.375, 6.335, 6.415, 6.535, 6.595 # <- XCS rates
163+
],
164+
fx=fxf,
165+
)
166+
167+
What is interesting to note about this particular *Solver* configuration is that nowhere does the
168+
*'inrusd'* discount *Curve* enter any *Instrument* specification. Since these *Instruments* have
169+
non-deliverable cashflows every discount *Curve* is the USD SOFR *Curve*. The key pricing component
170+
here is the ``fx=fxf`` object, which is a **pricing** parameter that *is* needed and is passed to
171+
all *Instruments*, and of course it derives forward FX rates using the *'inrusd'* *Curve* so
172+
everything is calibrated accurately.
173+
174+
The datasource (**DS**) for these prices also gives (wide) financial bid/ask for FX swaps and FX forwards.
175+
We can compare these with the :class:`~rateslib.fx.FXForwards` we have constructed through *rateslib* (**RL**)
176+
calibration.
177+
178+
.. ipython:: python
179+
:suppress:
180+
181+
from pandas import DataFrame
182+
from rateslib.dual.utils import _dual_float
183+
df = DataFrame({
184+
"tenor": ["1y", "2y", "3y", "4y", "5y"],
185+
"DS forward": [92.5112, 95.4512, 98.3212, 101.3112, 104.7912],
186+
"DS swap": [25300, 54700, 83400, 113300, 148100],
187+
"RL forward": [
188+
_dual_float(fxf.rate("usdinr", dt(2026, 12, 31))),
189+
_dual_float(fxf.rate("usdinr", dt(2027, 12, 31))),
190+
_dual_float(fxf.rate("usdinr", dt(2028, 12, 29))),
191+
_dual_float(fxf.rate("usdinr", dt(2029, 12, 31))),
192+
_dual_float(fxf.rate("usdinr", dt(2030, 12, 31))),
193+
],
194+
"RL swap": [
195+
_dual_float(fxf.swap("usdinr", [dt(2025, 12, 31), dt(2026, 12, 31)])),
196+
_dual_float(fxf.swap("usdinr", [dt(2025, 12, 31), dt(2027, 12, 31)])),
197+
_dual_float(fxf.swap("usdinr", [dt(2025, 12, 31), dt(2028, 12, 29)])),
198+
_dual_float(fxf.swap("usdinr", [dt(2025, 12, 31), dt(2029, 12, 31)])),
199+
_dual_float(fxf.swap("usdinr", [dt(2025, 12, 31), dt(2030, 12, 31)])),
200+
]
201+
})
202+
203+
.. ipython:: python
204+
205+
df
206+
207+
Lets have a look at the calibrate *Curves* thus far:
208+
209+
.. ipython:: python
210+
211+
usd.plot("1b", comparators=[inr, inrusd], labels=["SOFR", "ON/MIBOR", "ON/MIBOR+Basis"])
212+
213+
.. plot::
214+
215+
from rateslib import dt, Solver, Curve, FXRates, FXForwards, IRS, NDXCS
216+
import matplotlib.pyplot as plt
217+
218+
usd = Curve(
219+
nodes={
220+
dt(2025, 12, 29): 1.0,
221+
dt(2026, 12, 29): 1.0,
222+
dt(2027, 12, 29): 1.0,
223+
dt(2028, 12, 29): 1.0,
224+
dt(2029, 12, 29): 1.0,
225+
dt(2031, 1, 7): 1.0,
226+
},
227+
convention="Act360",
228+
calendar="nyc",
229+
interpolation="spline",
230+
id="sofr"
231+
)
232+
us_solver = Solver(
233+
curves=[usd],
234+
instruments=[
235+
IRS(dt(2025, 12, 31), "1y", spec="usd_irs", curves="sofr"),
236+
IRS(dt(2025, 12, 31), "2y", spec="usd_irs", curves="sofr"),
237+
IRS(dt(2025, 12, 31), "3y", spec="usd_irs", curves="sofr"),
238+
IRS(dt(2025, 12, 31), "4y", spec="usd_irs", curves="sofr"),
239+
IRS(dt(2025, 12, 31), "5y", spec="usd_irs", curves="sofr"),
240+
],
241+
s=[3.434, 3.302, 3.314, 3.359, 3.416],
242+
)
243+
244+
inr = Curve(
245+
nodes={
246+
dt(2025, 12, 29): 1.0,
247+
dt(2026, 12, 29): 1.0,
248+
dt(2027, 12, 29): 1.0,
249+
dt(2028, 12, 29): 1.0,
250+
dt(2029, 12, 29): 1.0,
251+
dt(2031, 1, 7): 1.0,
252+
},
253+
convention="Act365F",
254+
calendar="mum",
255+
id="mibor-ois"
256+
)
257+
258+
inrusd = Curve(
259+
nodes={
260+
dt(2025, 12, 29): 1.0,
261+
dt(2026, 12, 29): 1.0,
262+
dt(2027, 12, 29): 1.0,
263+
dt(2028, 12, 29): 1.0,
264+
dt(2029, 12, 29): 1.0,
265+
dt(2031, 1, 7): 1.0,
266+
},
267+
convention="Act365F",
268+
calendar="all", # <- no holiday calendar necessary for a cross-currency discount curve.
269+
id="inrusd"
270+
)
271+
272+
fxf = FXForwards(
273+
fx_rates=FXRates({"usdinr": 89.9812}, settlement=dt(2025, 12, 31)),
274+
fx_curves={"usdusd": usd, "inrinr": inr, "inrusd": inrusd},
275+
)
276+
277+
inr_solver = Solver(
278+
pre_solvers=[us_solver],
279+
curves=[inr, inrusd],
280+
instruments=[
281+
IRS(dt(2025, 12, 30), "1Y", spec="inr_ndirs", curves=["mibor-ois", "sofr"]),
282+
IRS(dt(2025, 12, 30), "2Y", spec="inr_ndirs", curves=["mibor-ois", "sofr"]),
283+
IRS(dt(2025, 12, 30), "3Y", spec="inr_ndirs", curves=["mibor-ois", "sofr"]),
284+
IRS(dt(2025, 12, 30), "4Y", spec="inr_ndirs", curves=["mibor-ois", "sofr"]),
285+
IRS(dt(2025, 12, 30), "5Y", spec="inr_ndirs", curves=["mibor-ois", "sofr"]),
286+
NDXCS(dt(2025, 12, 31), "1Y", spec="inrusd_ndxcs", curves=[None, "sofr", "sofr", "sofr"]),
287+
NDXCS(dt(2025, 12, 31), "2Y", spec="inrusd_ndxcs", curves=[None, "sofr", "sofr", "sofr"]),
288+
NDXCS(dt(2025, 12, 31), "3Y", spec="inrusd_ndxcs", curves=[None, "sofr", "sofr", "sofr"]),
289+
NDXCS(dt(2025, 12, 31), "4Y", spec="inrusd_ndxcs", curves=[None, "sofr", "sofr", "sofr"]),
290+
NDXCS(dt(2025, 12, 31), "5Y", spec="inrusd_ndxcs", curves=[None, "sofr", "sofr", "sofr"]),
291+
],
292+
s=[5.47, 5.5525, 5.715, 5.835, 5.925, 6.375, 6.335, 6.415, 6.535, 6.595],
293+
fx=fxf,
294+
)
295+
296+
fig, ax, line = usd.plot("1b", comparators=[inr, inrusd], labels=["SOFR", "ON/MIBOR", "ON/MIBOR+Basis"])
297+
plt.show()
298+
plt.close()

python/rateslib/_spec_loader.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ def _get_kwargs(spec: str) -> dict[str, Any]:
9090
d["roll"] = _map_str_int(d["roll"])
9191
if "leg2_roll" in d:
9292
d["leg2_roll"] = _map_str_int(d["leg2_roll"])
93-
return d # type: ignore[return-value] # this is [Hashable, Any] not [str, Any]
93+
return d
9494

9595
INSTRUMENT_SPECS = {k: _get_kwargs(k) for k in df.columns[4:]}
9696

python/rateslib/instruments/bonds/conventions/__init__.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,6 @@ class BondCalcMode:
9898
arguments. The available values are:
9999
100100
- ``linear_days``: A calendar day, linear proportion used in any period.
101-
(Used by UK and German GBs).
102101
103102
.. math::
104103
@@ -107,14 +106,15 @@ class BondCalcMode:
107106
- ``linear_days_long_front_split``: A modified version of the above which, **only for long
108107
stub** periods, uses a different formula treating the first quasi period as part of the
109108
long stub differently. This adjustment is then scaled according to the length of the period.
110-
(Treasury method for US Treasuries, see Section 31B ii A.356, Code of Federal Regulations)
109+
(Used by UK and German GBs and is the Treasury method for US Treasuries,
110+
see Section 31B ii A.356, Code of Federal Regulations)
111111
112112
.. math::
113113
114114
\\xi = (\\bar{r}_u / \\bar{s}_u + r_u / s_u) / ( d_i * f )
115115
116116
- ``30e360_backward``: For **stubs** this method reverts to ``linear_days``. Otherwise,
117-
determines the DCF, under the required convention, of the remaining part of the coupon
117+
determines the DCF, under *'30e360'* convention, of the remaining part of the coupon
118118
period from settlement and deducts this from the full accrual fraction.
119119
120120
.. math::
@@ -146,15 +146,15 @@ class BondCalcMode:
146146
.. ipython:: python
147147
148148
def _linear_days(obj, settlement, acc_idx, *args) -> float:
149-
sch = obj.leg1.schedule
150-
r_u = (settlement - sch.aschedule[acc_idx]).days
149+
sch = obj.leg1.schedule # <- obj is always the Bond itself
150+
r_u = (settlement - sch.aschedule[acc_idx]).days # <- acc_idx accesses the correct date
151151
s_u = (sch.aschedule[acc_idx + 1] - sch.aschedule[acc_idx]).days
152152
return r_u / s_u
153153
154154
Yield-To-Maturity
155155
-----------------
156156
157-
Yield-to-maturity in *rateslib*, for *every bond*, is calculated using the below formula.
157+
Yield-to-maturity in *rateslib*, for **every bond**, is calculated using the below formula.
158158
The specific discounting and cashflow generating functions must be provided to determine
159159
values based on the conventions of that specific bond. The cases where the number of remaining
160160
coupons are 1, 2, or generically >2 are outlined explicitly:

python/rateslib/instruments/bonds/protocols/accrued.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,16 @@ def accrued(self, settlement: datetime) -> DualTypes:
5454
5555
Notes
5656
-----
57-
Fractionally apportions the coupon payment based on calendar days.
57+
The amount of accrued interest is calculated using the following formula:
5858
5959
.. math::
6060
61-
\\text{Accrued} = \\text{Coupon} \\times \\frac{\\text{Settle - Last Coupon}}{\\text{Next Coupon - Last Coupon}}
61+
&AI = \\xi c_i \\qquad \\text{if not ex-dividend} \\\\
62+
&AI = (\\xi - 1) c_i \\qquad \\text{if ex-dividend} \\\\
63+
64+
where :math:`c_i` is the physical ``cashflow`` related to the period in which ``settlement``
65+
falls, and :math:`\\xi` is a fraction of that amount determined according to the
66+
calculation mode specific to the :class:`~rateslib.instruments.BondCalcMode`.
6267
6368
""" # noqa: E501
6469
return self._accrued(settlement, self.kwargs.meta["calc_mode"]._settle_accrual)

python/rateslib/instruments/fx_options/risk_reversal.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ def npv(
249249
if local:
250250
df = DataFrame(results).fillna(0.0)
251251
df_sum = df.sum()
252-
_: DualTypes | dict[str, DualTypes] = df_sum.to_dict() # type: ignore[assignment]
252+
_: DualTypes | dict[str, DualTypes] = df_sum.to_dict()
253253
else:
254254
_ = sum(results) # type: ignore[arg-type]
255255
return _

python/rateslib/instruments/ndxcs.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,7 @@ def __init__(
605605
metric=metric,
606606
)
607607
instrument_args = dict( # these are hard coded arguments specific to this instrument
608+
leg2_currency=NoInput(1),
608609
initial_exchange=True,
609610
final_exchange=True,
610611
leg2_initial_exchange=True,

python/rateslib/instruments/portfolio.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ def npv(
140140
# Aggregate results:
141141
_ = DataFrame(results).fillna(0.0)
142142
_ = _.sum()
143-
local_npv = _.to_dict() # type: ignore[assignment]
143+
local_npv = _.to_dict()
144144

145145
# ret = {}
146146
# for result in results:

0 commit comments

Comments
 (0)