Skip to content

Commit eea93c0

Browse files
authored
REF: Leg spreads (#141) (#1061)
Co-authored-by: JHM Darbyshire (M1) <[email protected]>
1 parent d76bf79 commit eea93c0

File tree

7 files changed

+209
-28
lines changed

7 files changed

+209
-28
lines changed

python/rateslib/legs/components/fixed.py

Lines changed: 71 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,13 @@
2929
LegFixings,
3030
Schedule,
3131
Series,
32+
_BaseCurve_,
3233
_SettlementParams,
3334
datetime,
3435
int_,
3536
str_,
37+
_BaseCurve,
38+
FXForwards_,
3639
)
3740

3841

@@ -650,6 +653,56 @@ def fx_delivery(i: int) -> datetime:
650653
else:
651654
self._mtm_exchange_periods = None
652655

656+
def _spread(
657+
self,
658+
target_npv: DualTypes,
659+
rate_curve: CurveOption_,
660+
disc_curve: CurveOption_,
661+
index_curve: _BaseCurve_,
662+
fx: FXForwards_ = NoInput(0),
663+
) -> DualTypes:
664+
"""
665+
Calculates the ``fixed_rate`` to match a specific target NPV on the leg.
666+
667+
Parameters
668+
----------
669+
target_npv : float, Dual or Dual2
670+
The target NPV that an adjustment to the parameter will achieve. **Must
671+
be in local currency of the leg.**
672+
rate_curve : Curve or LineCurve
673+
The forecast curve passed to analytic delta calculation.
674+
disc_curve : Curve
675+
The discounting curve passed to analytic delta calculation.
676+
index_curve : _BaseCurve_
677+
The curve used for forecasting index values.
678+
fx : FXForwards, optional
679+
Required for multi-currency legs which are MTM exchanged.
680+
681+
Returns
682+
-------
683+
float, Dual, Dual2
684+
685+
Notes
686+
-----
687+
``FixedLeg`` and ``FloatLeg`` with a *"none_simple"* spread compound method have
688+
linear sensitivity to the spread. This can be calculated directly and
689+
exactly using an analytic delta calculation.
690+
691+
*"isda_compounding"* and *"isda_flat_compounding"* spread compound methods
692+
have non-linear sensitivity to the spread. This requires a root finding,
693+
iterative algorithm, which, coupled with very poor performance of calculating
694+
period rates under this method is exceptionally slow. We approximate this
695+
using first and second order AD and extrapolate a solution as a Taylor
696+
expansion. This results in approximation error.
697+
698+
Examples
699+
--------
700+
"""
701+
a_delta = self.local_analytic_delta(
702+
rate_curve=rate_curve, disc_curve=disc_curve, index_curve=index_curve, fx=fx
703+
)
704+
return -target_npv / a_delta
705+
653706

654707
class ZeroFixedLeg(_BaseLeg):
655708
"""
@@ -808,18 +861,28 @@ def fixed_rate(self, value: DualTypes_) -> None:
808861
def _spread(
809862
self,
810863
target_npv: DualTypes,
811-
fore_curve: CurveOption_,
812-
disc_curve: CurveOption_,
813-
fx: FX_ = NoInput(0),
864+
rate_curve: CurveOption_,
865+
disc_curve: _BaseCurve,
866+
index_curve: _BaseCurve_ = NoInput(0),
867+
fx: FXForwards_ = NoInput(0),
814868
) -> DualTypes:
815869
"""
816870
Overload the _spread calc to use analytic delta based on period rate
817871
"""
818-
a_delta = self._analytic_delta(fore_curve, disc_curve, fx, self.currency)
819-
period_rate = -target_npv / (a_delta * 100)
872+
873+
unindexed_target_npv = (
874+
target_npv / self._regular_periods[0].index_up(1.0, index_curve=index_curve)
875+
)
876+
unindexed_reference_target_npv = (
877+
unindexed_target_npv / self._regular_periods[0].convert_deliverable(1.0, fx=fx)
878+
)
879+
820880
f = self.schedule.periods_per_annum
821-
_: DualTypes = f * ((1 + period_rate * self.dcf / 100) ** (1 / (self.dcf * f)) - 1)
822-
return _ * 10000
881+
d = self._regular_periods[0].dcf
882+
N = self.settlement_params.notional
883+
w = disc_curve[self.settlement_params.payment]
884+
R = ((-unindexed_reference_target_npv / (N * w) + 1) ** (1 / (d * f)) - 1) * f * 10000.0
885+
return R
823886

824887

825888
class ZeroIndexLeg(_BaseLeg):
@@ -938,8 +1001,4 @@ def _spread(
9381001
"""
9391002
Overload the _spread calc to use analytic delta based on period rate
9401003
"""
941-
a_delta = self._analytic_delta(fore_curve, disc_curve, fx, self.currency)
942-
period_rate = -target_npv / (a_delta * 100)
943-
f = self.schedule.periods_per_annum
944-
_: DualTypes = f * ((1 + period_rate * self.dcf / 100) ** (1 / (self.dcf * f)) - 1)
945-
return _ * 10000
1004+
raise NotImplementedError()

python/rateslib/legs/components/float.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
datetime,
3535
int_,
3636
str_,
37+
FXForwards_
3738
)
3839

3940

@@ -371,8 +372,8 @@ def _spread(
371372
target_npv: DualTypes,
372373
rate_curve: CurveOption_,
373374
disc_curve: _BaseCurve_,
374-
fx: FX_ = NoInput(0),
375375
index_curve: _BaseCurve_ = NoInput(0),
376+
fx: FXForwards_ = NoInput(0),
376377
) -> DualTypes:
377378
"""
378379
Calculates an adjustment to the ``fixed_rate`` or ``float_spread`` to match
@@ -389,6 +390,8 @@ def _spread(
389390
The discounting curve passed to analytic delta calculation.
390391
fx : FXForwards, optional
391392
Required for multi-currency legs which are MTM exchanged.
393+
index_curve : _BaseCurve, optional
394+
The index curve used for forecasting index values.
392395
393396
Returns
394397
-------
@@ -411,7 +414,7 @@ def _spread(
411414
--------
412415
"""
413416
if self._is_linear:
414-
a_delta: DualTypes = self.analytic_delta( # type: ignore[assignment]
417+
a_delta: DualTypes = self.local_analytic_delta(
415418
rate_curve=rate_curve,
416419
disc_curve=disc_curve,
417420
index_curve=index_curve,
@@ -627,8 +630,4 @@ def _spread(
627630
"""
628631
Overload the _spread calc to use analytic delta based on period rate
629632
"""
630-
a_delta = self._analytic_delta(fore_curve, disc_curve, fx, self.currency)
631-
period_rate = -target_npv / (a_delta * 100)
632-
f = self.schedule.periods_per_annum
633-
_: DualTypes = f * ((1 + period_rate * self.dcf / 100) ** (1 / (self.dcf * f)) - 1)
634-
return _ * 10000
633+
raise NotImplementedError()

python/rateslib/legs/components/protocols/analytic_delta.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,58 @@ class _WithAnalyticDelta(Protocol):
2424
@property
2525
def periods(self) -> list[_BasePeriod]: ...
2626

27+
def local_analytic_delta(
28+
self,
29+
rate_curve: CurveOption_ = NoInput(0),
30+
index_curve: _BaseCurve_ = NoInput(0),
31+
disc_curve: _BaseCurve_ = NoInput(0),
32+
fx: FXForwards_ = NoInput(0),
33+
fx_vol: FXVolOption_ = NoInput(0),
34+
settlement: datetime_ = NoInput(0),
35+
forward: datetime_ = NoInput(0),
36+
) -> DualTypes:
37+
"""
38+
Calculate the analytic rate delta of a *Period* expressed in its local settlement currency.
39+
40+
Parameters
41+
----------
42+
rate_curve: _BaseCurve or dict of such indexed by string tenor, optional
43+
Used to forecast floating period rates, if necessary.
44+
index_curve: _BaseCurve, optional
45+
Used to forecast index values for indexation, if necessary.
46+
disc_curve: _BaseCurve, optional
47+
Used to discount cashflows.
48+
fx: FXForwards, optional
49+
The :class:`~rateslib.fx.FXForwards` object used for forecasting the
50+
``fx_fixing`` for deliverable cashflows, if necessary. Or, an
51+
:class:`~rateslib.fx.FXRates` object purely for immediate currency conversion.
52+
fx_vol: FXDeltaVolSmile, FXSabrSmile, FXDeltaVolSurface, FXSabrSurface, optional
53+
The FX volatility *Smile* or *Surface* object used for determining Black calendar
54+
day implied volatility values.
55+
settlement: datetime, optional
56+
The assumed settlement date of the *PV* determination. Used only to evaluate
57+
*ex-dividend* status.
58+
forward: datetime, optional
59+
The future date to project the *PV* to using the ``disc_curve``.
60+
61+
Returns
62+
-------
63+
float, Dual, Dual2, Variable
64+
"""
65+
local_analytic_delta: DualTypes = sum(
66+
_.try_local_analytic_delta(
67+
rate_curve=rate_curve,
68+
index_curve=index_curve,
69+
disc_curve=disc_curve,
70+
fx=fx,
71+
fx_vol=fx_vol,
72+
settlement=settlement,
73+
forward=forward,
74+
).unwrap()
75+
for _ in self.periods
76+
)
77+
return local_analytic_delta
78+
2779
def analytic_delta(
2880
self,
2981
*,

python/rateslib/periods/components/fixed_period.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,7 @@ class ZeroFixedPeriod(_BasePeriodStatic):
421421
convention="1",
422422
)
423423
period.cashflows()
424-
424+
425425
.. role:: red
426426
427427
.. role:: green

python/rateslib/periods/components/float_period.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -526,7 +526,7 @@ class ZeroFloatPeriod(_BasePeriodStatic):
526526
For *analytic delta* purposes the :math:`\xi=-z`.
527527
528528
.. rubric:: Examples
529-
529+
530530
.. ipython:: python
531531
:suppress:
532532
@@ -832,7 +832,7 @@ def unindexed_reference_cashflow(
832832
# determine each rate from individual Periods
833833
r_i = [period.rate(rate_curve=rate_curve) for period in self.float_periods]
834834
d_i = [period.period_params.dcf for period in self.float_periods]
835-
r = np.prod(1.0 + np.array(r_i) * np.array(d_i) / 100.0) - 1.0
835+
r: DualTypes = np.prod(1.0 + np.array(r_i) * np.array(d_i) / 100.0) - 1.0
836836
return -self.settlement_params.notional * r
837837

838838
def try_unindexed_reference_cashflow_analytic_delta(

python/tests/legs/test_legs_legacy.py

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1042,8 +1042,55 @@ def test_zero_fixed_spread(self, curve) -> None:
10421042
convention="ActAct",
10431043
fixed_rate=NoInput(0),
10441044
)
1045-
result = zfl._spread(13140821.29 * curve[dt(2027, 1, 1)], NoInput(0), curve)
1046-
assert (result / 100 - 2.50) < 1e-3
1045+
result = zfl._spread(
1046+
target_npv=13140821.29 * curve[dt(2027, 1, 1)],
1047+
rate_curve=NoInput(0),
1048+
disc_curve=curve,
1049+
)
1050+
assert abs(result / 100 - 2.50) < 1e-3
1051+
1052+
def test_zero_fixed_spread_indexed(self, curve) -> None:
1053+
zfl = ZeroFixedLeg(
1054+
schedule=Schedule(
1055+
effective=dt(2022, 1, 1),
1056+
termination="5y",
1057+
payment_lag=0,
1058+
frequency="A",
1059+
),
1060+
notional=-1e8,
1061+
convention="ActAct",
1062+
fixed_rate=NoInput(0),
1063+
index_base=100.0,
1064+
index_fixings=110.0,
1065+
)
1066+
result = zfl._spread(
1067+
target_npv=13140821.29 * curve[dt(2027, 1, 1)],
1068+
rate_curve=NoInput(0),
1069+
disc_curve=curve,
1070+
)
1071+
assert abs(result / 100 - 2.2826266057484057) < 1e-3
1072+
1073+
def test_zero_fixed_spread_non_deliverable(self, curve) -> None:
1074+
zfl = ZeroFixedLeg(
1075+
schedule=Schedule(
1076+
effective=dt(2022, 1, 1),
1077+
termination="5y",
1078+
payment_lag=0,
1079+
frequency="A",
1080+
),
1081+
notional=-1e8,
1082+
convention="ActAct",
1083+
fixed_rate=NoInput(0),
1084+
currency="usd",
1085+
pair="eurusd",
1086+
fx_fixings=2.0,
1087+
)
1088+
result = zfl._spread(
1089+
target_npv=13140821.29 * curve[dt(2027, 1, 1)],
1090+
rate_curve=NoInput(0),
1091+
disc_curve=curve,
1092+
)
1093+
assert abs(result / 100 - 1.2808477472765924) < 1e-3
10471094

10481095
def test_amortization_raises(self) -> None:
10491096
with pytest.raises(TypeError, match="unexpected keyword argument 'amortization'"):
@@ -1504,6 +1551,25 @@ def test_non_deliverable(self, curve):
15041551

15051552
# v2.5
15061553

1554+
def test_fixed_leg_spread(self, curve) -> None:
1555+
fixed_leg = FixedLeg(
1556+
schedule=Schedule(
1557+
effective=dt(2022, 1, 1),
1558+
termination=dt(2022, 7, 1),
1559+
payment_lag=2,
1560+
payment_lag_exchange=1,
1561+
frequency="Q",
1562+
),
1563+
notional=-1e9,
1564+
convention="Act360",
1565+
fixed_rate=4.00,
1566+
currency="usd",
1567+
)
1568+
result = fixed_leg._spread(
1569+
target_npv=20000000, disc_curve=curve, rate_curve=curve, index_curve=curve
1570+
)
1571+
assert abs(result - 403.9491881327746) < 1e-6
1572+
15071573
@pytest.mark.parametrize("initial", [True, False])
15081574
@pytest.mark.parametrize("final", [True, False])
15091575
@pytest.mark.parametrize("amortization", [True, False])
@@ -2587,7 +2653,12 @@ def test_mtm_leg_exchange_spread(self) -> None:
25872653
rate_curve=fxf.curve("usd", "usd"), disc_curve=fxf.curve("usd", "usd"), fx=fxf
25882654
)
25892655
# a_delta = leg.analytic_delta(fxf.curve("usd", "usd"), fxf.curve("usd", "usd"), fxf)
2590-
result = leg._spread(100, fxf.curve("usd", "usd"), fxf.curve("usd", "usd"), fxf)
2656+
result = leg._spread(
2657+
target_npv=100,
2658+
rate_curve=fxf.curve("usd", "usd"),
2659+
disc_curve=fxf.curve("usd", "usd"),
2660+
fx=fxf,
2661+
)
25912662
leg.float_spread = result
25922663
npv2 = leg.npv(
25932664
rate_curve=fxf.curve("usd", "usd"), disc_curve=fxf.curve("usd", "usd"), fx=fxf

python/tests/periods/test_periods_legacy.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2875,8 +2875,8 @@ def test_fixed_period_npv_raises(self, curve) -> None:
28752875
currency="usd",
28762876
)
28772877
with pytest.raises(
2878-
ValueError,
2879-
match=re.escape("`disc_curve` is required but it has not been provided, or c"),
2878+
TypeError,
2879+
match=re.escape("`curves` have not been supplied correctly"),
28802880
):
28812881
fixed_period.npv()
28822882

@@ -3454,7 +3454,7 @@ def test_cashflow_cashflows(self, curve, fxr, crv, fx) -> None:
34543454
assert result == expected
34553455

34563456
def test_cashflow_npv_raises(self, curve) -> None:
3457-
with pytest.raises(ValueError, match="`disc_curve` is required but it has not been pr"):
3457+
with pytest.raises(TypeError, match="`curves` have not been supplied correctly."):
34583458
Cashflow(notional=1e6, payment=dt(2022, 1, 1)).npv()
34593459
cashflow = Cashflow(notional=1e6, payment=dt(2022, 1, 1))
34603460
assert cashflow.analytic_delta(rate_curve=curve) == 0

0 commit comments

Comments
 (0)