diff --git a/python/rateslib/instruments/components/__init__.py b/python/rateslib/instruments/components/__init__.py index dc57de25..a18bbd06 100644 --- a/python/rateslib/instruments/components/__init__.py +++ b/python/rateslib/instruments/components/__init__.py @@ -1,6 +1,7 @@ from rateslib.instruments.components.cds import CDS from rateslib.instruments.components.fly import Fly -from rateslib.instruments.components.fx_exchange import FXExchange +from rateslib.instruments.components.fx_forward import FXForward +from rateslib.instruments.components.fx_swap import FXSwap from rateslib.instruments.components.fx_vol_value import FXVolValue from rateslib.instruments.components.iirs import IIRS from rateslib.instruments.components.irs import IRS @@ -28,6 +29,7 @@ "Fly", "Spread", "Value", - "FXExchange", + "FXForward", "FXVolValue", + "FXSwap", ] diff --git a/python/rateslib/instruments/components/fx_exchange.py b/python/rateslib/instruments/components/fx_forward.py similarity index 74% rename from python/rateslib/instruments/components/fx_exchange.py rename to python/rateslib/instruments/components/fx_forward.py index 9266c012..1098000a 100644 --- a/python/rateslib/instruments/components/fx_exchange.py +++ b/python/rateslib/instruments/components/fx_forward.py @@ -32,21 +32,50 @@ ) -class FXExchange(_BaseInstrument): +class FXForward(_BaseInstrument): """ - Create a simple exchange of two currencies. + Create a simple *FX exchange* composing two + :class:`~rateslib.legs.components.CustomLeg` + of individual :class:`~rateslib.periods.components.Cashflow` of different currencies. + + .. rubric:: Examples + + A sold EURUSD *FX forward* at 1.165 expressed in $10mm. + + .. ipython:: python + :suppress: + + from datetime import datetime as dt + from rateslib.instruments.components import FXForward + + .. ipython:: python + + fxfwd = FXForward( + settlement=dt(2022, 2, 24), + pair="eurusd", + leg2_notional=10e6, + fx_rate=1.165 + ) + fxfwd.cashflows() + + .. role:: red + + .. role:: green Parameters ---------- - settlement : datetime + settlement : datetime, :red:`required` The date of the currency exchange. - pair: str + pair: str, :red:`required` The currency pair of the exchange, e.g. "eurusd", using 3-digit iso codes. - fx_rate : float, optional - The FX rate used to derive the notional exchange on *Leg2*. - notional : float - The cashflow amount of the LHS currency. - curves : Curve, LineCurve, str or list of such, optional + notional : float, Dual, Dual2, Variable, :green:`optional (set by 'defaults')` + To define the notional of the trade in units of LHS pair use ``notional``. + leg2_notional : float, Dual, Dual2, Variable, :green:`optional (negatively inherited from leg1)` + To define the notional of the trade in units of RHS pair use ``leg2_notional``. + Only one of ``notional`` or ``leg2_notional`` can be specified. + fx_rate : float, :green:`optional` + The FX rate of ``pair`` defining the transaction price. If not given, set at pricing. + curves : Curve, LineCurve, str or list of such, :green:`optional` For *FXExchange* only discounting curves are required in each currency and not rate forecasting curves. The signature should be: `[None, eur_curve, None, usd_curve]` for a "eurusd" pair. @@ -126,20 +155,29 @@ def __init__( pair: str, fx_rate: DualTypes_ = NoInput(0), notional: DualTypes_ = NoInput(0), + leg2_notional: DualTypes_ = NoInput(0), curves: Curves_ = NoInput(0), ): + pair_ = pair.lower() + if isinstance(notional, NoInput) and isinstance(leg2_notional, NoInput): + notional = defaults.notional + elif not isinstance(notional, NoInput) and not isinstance(leg2_notional, NoInput): + raise ValueError("Only one of `notional` and `leg2_notional` can be given.") + user_args = dict( settlement=settlement, currency=pair[:3], leg2_currency=pair[3:6], - leg2_pair=pair, - leg2_fx_fixings=fx_rate, notional=notional, + leg2_notional=leg2_notional, curves=self._parse_curves(curves), ) instrument_args = dict( leg2_settlement=NoInput.inherit, - leg2_notional=NoInput.negate, + pair=NoInput(0), + leg2_pair=NoInput(0), + fx_fixings=NoInput(0), + leg2_fx_fixings=NoInput(0), ) # these are hard coded arguments specific to this instrument default_args = dict( notional=defaults.notional, @@ -150,12 +188,25 @@ def __init__( default_args=default_args, meta_args=["curves"], ) + + # allocate arguments to correct legs for non-deliverability + if isinstance(notional, NoInput): + self.kwargs.leg1["notional"] = -1.0 * self.kwargs.leg2["notional"] + self.kwargs.leg1["pair"] = pair_ + self.kwargs.leg1["fx_fixings"] = fx_rate + else: # notional set on leg1 + self.kwargs.leg2["notional"] = -1.0 * self.kwargs.leg1["notional"] + self.kwargs.leg2["pair"] = pair_ + self.kwargs.leg2["fx_fixings"] = fx_rate + self._leg1 = CustomLeg( periods=[ Cashflow( currency=self.kwargs.leg1["currency"], notional=-1.0 * self.kwargs.leg1["notional"], payment=self.kwargs.leg1["settlement"], + pair=self.kwargs.leg1["pair"], + fx_fixings=self.kwargs.leg1["fx_fixings"], ), ] ) diff --git a/python/rateslib/instruments/components/fx_swap.py b/python/rateslib/instruments/components/fx_swap.py new file mode 100644 index 00000000..3d2cc617 --- /dev/null +++ b/python/rateslib/instruments/components/fx_swap.py @@ -0,0 +1,496 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING + +from rateslib import defaults +from rateslib.enums.generics import NoInput, _drb +from rateslib.instruments.components.protocols import _BaseInstrument +from rateslib.instruments.components.protocols.kwargs import _KWArgs +from rateslib.instruments.components.protocols.pricing import ( + _Curves, + _get_fx_maybe_from_solver, + _get_maybe_curve_maybe_from_solver, +) +from rateslib.legs.components import CustomLeg +from rateslib.periods.components import Cashflow +from rateslib.scheduling import Schedule + +if TYPE_CHECKING: + from rateslib.typing import ( # pragma: no cover + CalInput, + CurveOption_, + Curves_, + DataFrame, + DualTypes, + DualTypes_, + FXForwards_, + FXVolOption_, + PeriodFixings, + RollDay, + Solver_, + _BaseLeg, + bool_, + datetime_, + str_, + ) + + +class FXSwap(_BaseInstrument): + """ + An *FX swap* composing two + :class:`~rateslib.legs.components.CustomLeg` + of individual :class:`~rateslib.periods.components.Cashflow` of different currencies. + + .. rubric:: Examples + + .. ipython:: python + :suppress: + + from datetime import datetime as dt + from rateslib.instruments.components.fx_swap import FXSwap + + Paying a 3M EURUSD *FX Swap* expressed in USD notional at 56.5 swap points. + + .. ipython:: python + + fxs = FXSwap( + effective=dt(2022, 1, 19), + termination="3m", + calendar="tgt|fed", + pair="eurusd", + leg2_notional=-10e6, + split_notional=-10.25e6, + fx_fixings=1.15, + points=56.5, + ) + fxs.cashflows() + + .. role:: red + + .. role:: green + + Parameters + ---------- + . + + .. note:: + + The following define generalised **scheduling** parameters. + + effective : datetime, :red:`required` + The settlement date of the first currency pair. + termination : datetime, str, :red:`required` + The settlement of the second currency pair. If given as string requires additional + scheduling arguments to derive from ``effective``. + roll : RollDay, int in [1, 31], str in {"eom", "imm", "som"}, :green:`optional` + If ``termination`` is str tenor, the roll day for its determination. + eom : bool, :green:`optional` + If ``termination`` is str tenor, the end-of-month preference if ``roll`` is not specified. + modifier : Adjuster, str in {"NONE", "F", "MF", "P", "MP"}, :green:`optional (set by 'defaults')` + If ``termination`` is str tenor, the adjustment to apply to its determination. + calendar : calendar, str, :green:`optional (set as 'all')` + If ``termination`` is str tenor, the calendar to apply to its determination. + + .. note:: + + The following define generalised **settlement** parameters. + + pair : str, :red:`required` + The FX pair of the *Instrument* (6-digit code). + notional : float, Dual, Dual2, Variable, :green:`optional (set by 'defaults')` + To define the notional of the trade in units of LHS pair use ``notional``. + leg2_notional : float, Dual, Dual2, Variable, :green:`optional (negatively inherited from leg1)` + To define the notional of the trade in units of RHS pair use ``leg2_notional``. + Only one of ``notional`` or ``leg2_notional`` can be specified. + split_notional: float, Variable, :green:`optional` + If the second cashflow has a rate adjusted notional to mitigate spot FX risk this is + entered as this argument. If not given the *FX Swap* is assumed not to have split notional. + Expressed in the same units as that given for either ``notional`` or ``leg2_notional``. + + .. note:: + + The following are **rate parameters**. + + fx_fixings : float, Dual, Dual2, Variable, :green:`optional` + If ``leg2_notional`` is given, this arguments can be provided to imply ``notional`` on leg1 + via non-deliverability. + leg2_fixings : float, Dual, Dual2, Variable, :green:`optional` + If ``notional`` is given, this argument can be provided to imply ``leg2_notional``, via + non-deliverability. + points : float, Dual, Dual2, Variable, :green:`optional` + If either ``fx_fixings`` or ``leg2_fx_fixings`` are given, this argument is required to + imply the second FX fixing. + + .. note:: + + The following are **meta parameters**. + + curves : XXX + Pricing objects passed directly to the *Instrument's* methods' ``curves`` argument. + spec: str, :green:`optional` + A collective group of parameters. See + :ref:`default argument specifications `. + + """ # noqa: E501 + + _rate_scalar = 1.0 + + @property + def leg1(self) -> CustomLeg: + """The :class:`~rateslib.legs.components.CustomLeg` of the *Instrument*.""" + return self._leg1 + + @property + def leg2(self) -> CustomLeg: + """The :class:`~rateslib.legs.components.CustomLeg` of the *Instrument*.""" + return self._leg2 + + @property + def legs(self) -> list[_BaseLeg]: + """A list of the *Legs* of the *Instrument*.""" + return self._legs + + def _parse_curves(self, curves: CurveOption_) -> _Curves: + """ + An NDF requires 1 curve to discount curve the settlement currency. + + When given as 2 elements the first is treated as the rate curve and the 2nd as disc curve. + """ + if isinstance(curves, NoInput): + return _Curves() + elif isinstance(curves, dict): + return _Curves( + disc_curve=curves.get("disc_curve", NoInput(0)), + leg2_disc_curve=_drb( + curves.get("disc_curve", NoInput(0)), + curves.get("leg2_disc_curve", NoInput(0)), + ), + ) + elif isinstance(curves, list | tuple): + if len(curves) == 2: + return _Curves( + disc_curve=curves[0], + leg2_disc_curve=curves[1], + ) + elif len(curves) == 4: + return _Curves( + disc_curve=curves[1], + leg2_disc_curve=curves[3], + ) + else: + raise ValueError( + f"{type(self).__name__} requires 1 curve types. Got {len(curves)}." + ) + + else: # `curves` is just a single input which is copied across all curves + return _Curves( + disc_curve=curves, + leg2_disc_curve=curves, + ) + + def __init__( + self, + # scheduling + effective: datetime, + termination: datetime | str, + pair: str, + *, + roll: int | RollDay | str_ = NoInput(0), + eom: bool_ = NoInput(0), + modifier: str_ = NoInput(0), + calendar: CalInput = NoInput(0), + # settlement + notional: DualTypes_ = NoInput(0), + leg2_notional: DualTypes_ = NoInput(0), + split_notional: DualTypes_ = NoInput(0), + # rate + fx_fixings: PeriodFixings = NoInput(0), + leg2_fx_fixings: PeriodFixings = NoInput(0), + points: DualTypes_ = NoInput(0), + # meta + curves: Curves_ = NoInput(0), + spec: str_ = NoInput(0), + ): + pair_ = pair.lower() + if isinstance(notional, NoInput) and isinstance(leg2_notional, NoInput): + notional = defaults.notional + elif not isinstance(notional, NoInput) and not isinstance(leg2_notional, NoInput): + raise ValueError("Only one of `notional` and `leg2_notional` can be given.") + + schedule = Schedule( + effective=effective, + termination=termination, + frequency="Z", + roll=roll, + eom=eom, + modifier=modifier, + calendar=calendar, + ) + + self._validate_init_combinations( + notional=notional, + leg2_notional=leg2_notional, + fx_fixings=fx_fixings, + leg2_fx_fixings=leg2_fx_fixings, + points=points, + ) + + user_args = dict( + effective=schedule.aschedule[0], + termination=schedule.aschedule[1], + leg2_effective=schedule.aschedule[0], + leg2_termination=schedule.aschedule[1], + notional=notional, + leg2_notional=leg2_notional, + fx_fixings=fx_fixings, + leg2_fx_fixings=leg2_fx_fixings, + points=points, + split_notional=split_notional, + curves=self._parse_curves(curves), + ) + + instrument_args = dict( # these are hard coded arguments specific to this instrument + currency=pair[:3], + leg2_currency=pair[3:6], + pair=NoInput(0), + leg2_pair=NoInput(0), + ) + default_args = dict() + self._kwargs = _KWArgs( + spec=spec, + user_args={**user_args, **instrument_args}, + default_args=default_args, + meta_args=[ + "curves", + "points", + "split_notional", + ], + ) + + if isinstance(notional, NoInput): + self.kwargs.leg1["notional"] = -1.0 * self.kwargs.leg2["notional"] + self.kwargs.leg1["pair"] = pair_ + if isinstance(split_notional, NoInput): + self.kwargs.leg1["split_notional"] = NoInput(0) + self.kwargs.leg2["split_notional"] = NoInput(0) + else: + self.kwargs.leg1["split_notional"] = split_notional + self.kwargs.leg2["split_notional"] = -split_notional + else: # notional set on leg1 + self.kwargs.leg2["notional"] = -1.0 * self.kwargs.leg1["notional"] + self.kwargs.leg2["pair"] = pair_ + if isinstance(split_notional, NoInput): + self.kwargs.leg2["split_notional"] = NoInput(0) + self.kwargs.leg1["split_notional"] = NoInput(0) + else: + self.kwargs.leg2["split_notional"] = split_notional + self.kwargs.leg1["split_notional"] = -split_notional + + # construct legs + if isinstance(self.kwargs.leg1["fx_fixings"], NoInput): + fx_fixings_2 = NoInput(0) + else: + fx_fixings_2 = self.kwargs.leg1["fx_fixings"] + self.kwargs.meta["points"] / 10000.0 + if isinstance(self.kwargs.leg2["fx_fixings"], NoInput): + leg2_fx_fixings_2 = NoInput(0) + else: + leg2_fx_fixings_2 = ( + self.kwargs.leg2["fx_fixings"] + self.kwargs.meta["points"] / 10000.0 + ) + + self._leg1 = CustomLeg( + periods=[ + Cashflow( + currency=self.kwargs.leg1["currency"], + notional=self.kwargs.leg1["notional"], + payment=self.kwargs.leg1["effective"], + pair=self.kwargs.leg1["pair"], + fx_fixings=self.kwargs.leg1["fx_fixings"], + ), + Cashflow( + currency=self.kwargs.leg1["currency"], + notional=-1.0 * self.kwargs.leg1["notional"] + if isinstance(split_notional, NoInput) + else self.kwargs.leg1["split_notional"], + payment=self.kwargs.leg1["termination"], + pair=self.kwargs.leg1["pair"], + fx_fixings=fx_fixings_2, + ), + ] + ) + self._leg2 = CustomLeg( + periods=[ + Cashflow( + currency=self.kwargs.leg2["currency"], + notional=self.kwargs.leg2["notional"], + payment=self.kwargs.leg2["effective"], + pair=self.kwargs.leg2["pair"], + fx_fixings=self.kwargs.leg2["fx_fixings"], + ), + Cashflow( + currency=self.kwargs.leg2["currency"], + notional=-1.0 * self.kwargs.leg2["notional"] + if isinstance(split_notional, NoInput) + else self.kwargs.leg2["split_notional"], + payment=self.kwargs.leg2["termination"], + pair=self.kwargs.leg2["pair"], + fx_fixings=leg2_fx_fixings_2, + ), + ] + ) + self._legs = [self._leg1, self._leg2] + + def _validate_init_combinations( + self, + notional: DualTypes_, + leg2_notional: DualTypes_, + fx_fixings: PeriodFixings, + leg2_fx_fixings: PeriodFixings, + points: DualTypes_, + ) -> None: + if not isinstance(fx_fixings, NoInput): + if not isinstance(notional, NoInput): + raise ValueError( + "When `notional` is given only `leg2_fx_fixings` are required to derive " + "cashflows on leg2 via non-deliverability." + ) + if isinstance(points, NoInput): + raise ValueError( + "An FXSwap must set ``fx_fixings`` and ``points`` simultaneously to determine" + "a properly initialized FXSwap object.\n Only ``fx_fixings`` was given." + ) + if not isinstance(leg2_fx_fixings, NoInput): + if not isinstance(leg2_notional, NoInput): + raise ValueError( + "When `leg2_notional` is given only `fx_fixings` are required to derive " + "cashflows on leg1 via non-deliverability." + ) + if isinstance(points, NoInput): + raise ValueError( + "An FXSwap must set ``fx_fixings`` and ``points`` simultaneously to determine" + "a properly initialized FXSwap object.\n Only ``fx_fixings`` was given." + ) + + if not isinstance(points, NoInput) and ( + isinstance(leg2_fx_fixings, NoInput) and isinstance(fx_fixings, NoInput) + ): + raise ValueError( + "`points` has been set on an FXSwap without a defined `fx_fixings` or " + "`leg2_fx_fixings`.\nThe initial FXFixing is required to determine the cashflow " + "exchanges at maturity." + ) + + if not isinstance(notional, NoInput) and not isinstance(fx_fixings, NoInput): + raise ValueError( + "When `notional` is given only `leg2_fx_fixings` is required to derive " + "cashflows on leg2 via non-deliverability." + ) + if not isinstance(leg2_notional, NoInput) and not isinstance(leg2_fx_fixings, NoInput): + raise ValueError( + "When `leg2_notional` is given only `fx_fixings` is required to derive " + "cashflows on leg1 via non-deliverability." + ) + + def cashflows( + self, + *, + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + fx_vol: FXVolOption_ = NoInput(0), + base: str_ = NoInput(0), + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> DataFrame: + return super()._cashflows_from_legs( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + base=base, + settlement=settlement, + forward=forward, + ) + + def rate( + self, + *, + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + fx_vol: FXVolOption_ = NoInput(0), + base: str_ = NoInput(0), + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + metric: str_ = NoInput(0), + ) -> DualTypes_: + if isinstance(self.kwargs.leg1["pair"], NoInput): + # then non-deliverability and fx_fixing are on leg2 + return self._rate_on_leg( + core_leg="leg1", nd_leg="leg2", curves=curves, fx=fx, solver=solver + ) + else: + # then non-deliverability and fx_fixing are on leg1 + return self._rate_on_leg( + core_leg="leg2", nd_leg="leg1", curves=curves, fx=fx, solver=solver + ) + + def _rate_on_leg( + self, + core_leg: str, + nd_leg: str, + *, + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + ) -> DualTypes_: + _curves = self._parse_curves(curves) + fx_ = _get_fx_maybe_from_solver(solver=solver, fx=fx) + + core_curve = "" if core_leg == "leg1" else "leg2_" + nd_curve = "" if nd_leg == "leg1" else "leg2_" + core_leg = getattr(self, core_leg) + nd_leg = getattr(self, nd_leg) + + # then non-deliverability and fx_fixing are on leg2 + core_npv = core_leg.npv( + disc_curve=_get_maybe_curve_maybe_from_solver( + self.kwargs.meta["curves"], _curves, f"{core_curve}disc_curve", solver + ), + base=self.leg2.settlement_params.currency, + fx=fx_, + ) + nd_disc_curve = _get_maybe_curve_maybe_from_solver( + self.kwargs.meta["curves"], _curves, f"{nd_curve}disc_curve", solver + ) + nd_cf1_npv = self.leg2.periods[0].local_npv(disc_curve=nd_disc_curve, fx=fx_) + net_zero_cf = (core_npv + nd_cf1_npv) / nd_disc_curve[ + nd_leg.periods[1].settlement_params.payment + ] + required_fx = net_zero_cf / nd_leg.periods[1].settlement_params.notional + original_fx = nd_leg.periods[0].non_deliverable_params.fx_fixing.value_or_forecast(fx=fx_) + return (required_fx - original_fx) * 10000.0 + + def npv( + self, + *, + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + fx_vol: FXVolOption_ = NoInput(0), + base: str_ = NoInput(0), + local: bool = False, + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> DualTypes | dict[str, DualTypes]: + return super().npv( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + base=base, + local=local, + settlement=settlement, + forward=forward, + ) diff --git a/python/rateslib/instruments/components/fx_vol_value.py b/python/rateslib/instruments/components/fx_vol_value.py index fdb39d18..d2d08390 100644 --- a/python/rateslib/instruments/components/fx_vol_value.py +++ b/python/rateslib/instruments/components/fx_vol_value.py @@ -136,7 +136,7 @@ def rate( _vol: _Vol = self._parse_vol(vol) metric_ = _drb(self.kwargs.meta["metric"], metric).lower() - if metric == "vol": + if metric_ == "vol": vol_ = _get_maybe_fx_vol_maybe_from_solver( vol_meta=self.kwargs.meta["vol"], solver=solver, vol=_vol ) diff --git a/python/rateslib/instruments/components/irs.py b/python/rateslib/instruments/components/irs.py index 7fe35020..eff42b6e 100644 --- a/python/rateslib/instruments/components/irs.py +++ b/python/rateslib/instruments/components/irs.py @@ -40,7 +40,7 @@ class IRS(_BaseInstrument): """ - Create an *interest rate swap (IRS)* composing a :class:`~rateslib.legs.components.FixedLeg` + An *interest rate swap (IRS)* composing a :class:`~rateslib.legs.components.FixedLeg` and a :class:`~rateslib.legs.components.FloatLeg`. .. role:: red @@ -194,7 +194,7 @@ class IRS(_BaseInstrument): -------- Construct a curve to price the example. - """ + """ # noqa: E501 _rate_scalar = 1.0 diff --git a/python/rateslib/instruments/components/protocols/analytic_delta.py b/python/rateslib/instruments/components/protocols/analytic_delta.py index aaf2b38e..33335cdb 100644 --- a/python/rateslib/instruments/components/protocols/analytic_delta.py +++ b/python/rateslib/instruments/components/protocols/analytic_delta.py @@ -92,7 +92,7 @@ def analytic_delta( for this conversion although best practice does not recommend it due to possible settlement date conflicts. """ - assert hasattr(self, "legs") + assert hasattr(self, "legs") # noqa: S101 _curves: _Curves = self._parse_curves(curves) _curves_meta: _Curves = self.kwargs.meta["curves"] diff --git a/python/rateslib/instruments/components/protocols/analytic_fixings.py b/python/rateslib/instruments/components/protocols/analytic_fixings.py index 7e812a40..ce7d2b48 100644 --- a/python/rateslib/instruments/components/protocols/analytic_fixings.py +++ b/python/rateslib/instruments/components/protocols/analytic_fixings.py @@ -116,7 +116,7 @@ def _local_analytic_rate_fixings_from_legs( settlement: datetime_ = NoInput(0), forward: datetime_ = NoInput(0), ) -> DataFrame: - assert hasattr(self, "legs") + assert hasattr(self, "legs") # noqa: S101 # this is a generic implementation to handle 2 legs. _curves: _Curves = self._parse_curves(curves) @@ -175,7 +175,7 @@ def _local_analytic_rate_fixings_from_instruments( settlement: datetime_ = NoInput(0), forward: datetime_ = NoInput(0), ) -> DataFrame: - assert hasattr(self, "instruments") + assert hasattr(self, "instruments") # noqa: S101 df_result = DataFrame(index=DatetimeIndex([], name="obs_dates")) for inst in self.instruments: diff --git a/python/rateslib/instruments/components/protocols/cashflows.py b/python/rateslib/instruments/components/protocols/cashflows.py index b23d7477..790479f6 100644 --- a/python/rateslib/instruments/components/protocols/cashflows.py +++ b/python/rateslib/instruments/components/protocols/cashflows.py @@ -96,7 +96,7 @@ def _cashflows_from_legs( # this is a generalist implementation of an NPV function for an instrument with 2 legs. # most instruments may be likely to implement NPV directly to benefit from optimisations # specific to that instrument - assert hasattr(self, "legs") + assert hasattr(self, "legs") # noqa: S101 _curves: _Curves = self._parse_curves(curves) _curves_meta: _Curves = self.kwargs.meta["curves"] @@ -149,7 +149,7 @@ def _cashflows_from_instruments(self, *args: Any, **kwargs: Any) -> DataFrame: # this is a generalist implementation of an NPV function for an instrument with 2 legs. # most instruments may be likely to implement NPV directly to benefit from optimisations # specific to that instrument - assert hasattr(self, "instruments") + assert hasattr(self, "instruments") # noqa: S101 _: DataFrame = concat( [_.cashflows(*args, **kwargs) for _ in self.instruments], diff --git a/python/rateslib/instruments/components/protocols/npv.py b/python/rateslib/instruments/components/protocols/npv.py index fc2c8cb5..da93f781 100644 --- a/python/rateslib/instruments/components/protocols/npv.py +++ b/python/rateslib/instruments/components/protocols/npv.py @@ -97,7 +97,7 @@ def npv( # this is a generalist implementation of an NPV function for an instrument with 2 legs. # most instruments may be likely to implement NPV directly to benefit from optimisations # specific to that instrument - assert hasattr(self, "legs") + assert hasattr(self, "legs") # noqa: S101 _curves: _Curves = self._parse_curves(curves) _curves_meta: _Curves = self.kwargs.meta["curves"] @@ -168,7 +168,7 @@ def _npv_single_core( """ Private NPV summation function used with a single thread, over all `self.instruments`. """ - assert hasattr(self, "instruments") + assert hasattr(self, "instruments") # noqa: S101 local_npv: dict[str, DualTypes] = {} for instrument in self.instruments: diff --git a/python/rateslib/instruments/components/protocols/utils.py b/python/rateslib/instruments/components/protocols/utils.py index cf9c4f39..c92e3a18 100644 --- a/python/rateslib/instruments/components/protocols/utils.py +++ b/python/rateslib/instruments/components/protocols/utils.py @@ -7,7 +7,6 @@ if TYPE_CHECKING: from rateslib.typing import ( CurveOption_, - _BaseCurve, ) @@ -65,51 +64,51 @@ def _maybe_set_ad_order( return original_order -def _map_fx_vol_or_id_from_solver_(curve: CurveOrId, solver: Solver) -> _BaseCurve: - """ - Maps a "FXVol | str" to a "Curve" via a Solver mapping. - - If a Curve, runs a check against whether that Curve is associated with the given Solver, - and perform an action based on `defaults.curve_not_in_solver` - """ - if isinstance(curve, str): - return solver._get_pre_curve(curve) - elif type(curve) is ProxyCurve or type(curve) is MultiCsaCurve: - # TODO: (mid) consider also adding CompositeCurves as exceptions under the same rule - # Proxy curves and MultiCsaCurves can exist outside of Solvers but be constructed - # directly from an FXForwards object tied to a Solver using only a Solver's - # dependent curves and AD variables. - return curve - else: - try: - # it is a safeguard to load curves from solvers when a solver is - # provided and multiple curves might have the same id - __: _BaseCurve = solver._get_pre_curve(curve.id) - if id(__) != id(curve): # Python id() is a memory id, not a string label id. - raise ValueError( - "A curve has been supplied, as part of ``curves``, which has the same " - f"`id` ('{curve.id}'),\nas one of the curves available as part of the " - "Solver's collection but is not the same object.\n" - "This is ambiguous and cannot price.\n" - "Either refactor the arguments as follows:\n" - "1) remove the conflicting curve: [curves=[..], solver=] -> " - "[curves=None, solver=]\n" - "2) change the `id` of the supplied curve and ensure the rateslib.defaults " - "option 'curve_not_in_solver' is set to 'ignore'.\n" - " This will remove the ability to accurately price risk metrics.", - ) - return __ - except AttributeError: - raise AttributeError( - "`curve` has no attribute `id`, likely it not a valid object, got: " - f"{curve}.\nSince a solver is provided have you missed labelling the `curves` " - f"of the instrument or supplying `curves` directly?", - ) - except KeyError: - if defaults.curve_not_in_solver == "ignore": - return curve - elif defaults.curve_not_in_solver == "warn": - warnings.warn("`curve` not found in `solver`.", UserWarning) - return curve - else: - raise ValueError("`curve` must be in `solver`.") +# def _map_fx_vol_or_id_from_solver_(curve: CurveOrId, solver: Solver) -> _BaseCurve: +# """ +# Maps a "FXVol | str" to a "Curve" via a Solver mapping. +# +# If a Curve, runs a check against whether that Curve is associated with the given Solver, +# and perform an action based on `defaults.curve_not_in_solver` +# """ +# if isinstance(curve, str): +# return solver._get_pre_curve(curve) +# elif type(curve) is ProxyCurve or type(curve) is MultiCsaCurve: +# # TODO: (mid) consider also adding CompositeCurves as exceptions under the same rule +# # Proxy curves and MultiCsaCurves can exist outside of Solvers but be constructed +# # directly from an FXForwards object tied to a Solver using only a Solver's +# # dependent curves and AD variables. +# return curve +# else: +# try: +# # it is a safeguard to load curves from solvers when a solver is +# # provided and multiple curves might have the same id +# __: _BaseCurve = solver._get_pre_curve(curve.id) +# if id(__) != id(curve): # Python id() is a memory id, not a string label id. +# raise ValueError( +# "A curve has been supplied, as part of ``curves``, which has the same " +# f"`id` ('{curve.id}'),\nas one of the curves available as part of the " +# "Solver's collection but is not the same object.\n" +# "This is ambiguous and cannot price.\n" +# "Either refactor the arguments as follows:\n" +# "1) remove the conflicting curve: [curves=[..], solver=] -> " +# "[curves=None, solver=]\n" +# "2) change the `id` of the supplied curve and ensure the rateslib.defaults " +# "option 'curve_not_in_solver' is set to 'ignore'.\n" +# " This will remove the ability to accurately price risk metrics.", +# ) +# return __ +# except AttributeError: +# raise AttributeError( +# "`curve` has no attribute `id`, likely it not a valid object, got: " +# f"{curve}.\nSince a solver is provided have you missed labelling the `curves` " +# f"of the instrument or supplying `curves` directly?", +# ) +# except KeyError: +# if defaults.curve_not_in_solver == "ignore": +# return curve +# elif defaults.curve_not_in_solver == "warn": +# warnings.warn("`curve` not found in `solver`.", UserWarning) +# return curve +# else: +# raise ValueError("`curve` must be in `solver`.") diff --git a/python/rateslib/instruments/components/stir_future.py b/python/rateslib/instruments/components/stir_future.py index 9de62860..4bf14f44 100644 --- a/python/rateslib/instruments/components/stir_future.py +++ b/python/rateslib/instruments/components/stir_future.py @@ -152,7 +152,8 @@ def legs(self) -> list[_BaseLeg]: def _parse_curves(self, curves: CurveOption_) -> _Curves: """ - An STIRFuture has two curve requirements: a leg2_rate_curve and a disc_curve used by both legs. + An STIRFuture has two curve requirements: a leg2_rate_curve and a disc_curve used by + both legs. When given as only 1 element this curve is applied to all of the those components diff --git a/python/rateslib/instruments/components/xcs.py b/python/rateslib/instruments/components/xcs.py index 797cc4a2..362bb790 100644 --- a/python/rateslib/instruments/components/xcs.py +++ b/python/rateslib/instruments/components/xcs.py @@ -42,7 +42,7 @@ class XCS(_BaseInstrument): """ - Create a *cross-currency swap (XCS)* composing either + A *cross-currency swap (XCS)* composing either :class:`~rateslib.legs.components.FixedLeg` and/or :class:`~rateslib.legs.components.FloatLeg` in different currencies. @@ -65,6 +65,7 @@ class XCS(_BaseInstrument): spec="eurusd_xcs", notional=5e6, leg2_fx_fixings=(1.15, "EURUSD_1600_GMT"), + leg2_mtm=True, ) xcs.cashflows() @@ -75,7 +76,16 @@ class XCS(_BaseInstrument): .. rubric:: Pricing - TBD + The methods of a *XCS* require an :class:`~rateslib.fx.FXForwards` object for ``fx``, and + **four** :class:`~rateslib.curves._BaseCurve` for ``curves``; + + - a **rate_curve**, + - a **disc_curve**, + - a **leg2_rate_curve**, + - a **leg2_disc_curve** (for pricing consistency the collateral of each discount curve should + be the same). + + If given as a list these should be specified in this order. .. role:: red @@ -256,7 +266,7 @@ class XCS(_BaseInstrument): non-deliverability and either ``leg2_fx_fixings`` or ``fx_fixings`` respectively. These fixings are always expressed using an FX rate of direction *'currency:leg2_currency'*. One requirement is that if any leg is ``mtm`` then that leg cannot set the defining notional; the notional must - be set on the + be set on the non-mtm leg. **For example**, we initialise a MTM GBP/USD XCS in £100m. The MTM leg is USD so the notional must be expressed on the GBP leg. The pricing spread is applied to the GBP leg. @@ -294,7 +304,7 @@ class XCS(_BaseInstrument): ) xcs.cashflows() - """ + """ # noqa: E501 _rate_scalar = 1.0 @@ -462,18 +472,16 @@ def __init__( "The other leg's cashflows are derived via `fx_fixings` and non-deliverability." ) - if not isinstance(notional, NoInput): - if not isinstance(fx_fixings, NoInput): - raise ValueError( - "When `notional` is given only `leg2_fx_fixings` are required to derive " - "cashflows on leg2 via non-deliverability." - ) - if not isinstance(leg2_notional, NoInput): - if not isinstance(leg2_fx_fixings, NoInput): - raise ValueError( - "When `leg2_notional` is given only `fx_fixings` are required to derive " - "cashflows on leg1 via non-deliverability." - ) + if not isinstance(notional, NoInput) and not isinstance(fx_fixings, NoInput): + raise ValueError( + "When `notional` is given only `leg2_fx_fixings` are required to derive " + "cashflows on leg2 via non-deliverability." + ) + if not isinstance(leg2_notional, NoInput) and not isinstance(leg2_fx_fixings, NoInput): + raise ValueError( + "When `leg2_notional` is given only `fx_fixings` are required to derive " + "cashflows on leg1 via non-deliverability." + ) user_args = dict( # scheduling @@ -814,14 +822,7 @@ def _parse_curves(self, curves: CurveOption_) -> _Curves: leg2_disc_curve=curves.get("leg2_disc_curve", NoInput(0)), ) elif isinstance(curves, list | tuple): - if len(curves) == 2: - return _Curves( - rate_curve=curves[0], - disc_curve=curves[0], - leg2_rate_curve=curves[1], - leg2_disc_curve=curves[1], - ) - elif len(curves) == 4: + if len(curves) == 4: return _Curves( rate_curve=curves[0], disc_curve=curves[1], diff --git a/python/rateslib/instruments/components/zcis.py b/python/rateslib/instruments/components/zcis.py index 552dba8e..e490fd64 100644 --- a/python/rateslib/instruments/components/zcis.py +++ b/python/rateslib/instruments/components/zcis.py @@ -166,7 +166,7 @@ class ZCIS(_BaseInstrument): -------- Construct a curve to price the example. - """ + """ # noqa: E501 _rate_scalar = 1.0 diff --git a/python/rateslib/instruments/components/zcs.py b/python/rateslib/instruments/components/zcs.py index 5ea8940a..5212ae04 100644 --- a/python/rateslib/instruments/components/zcs.py +++ b/python/rateslib/instruments/components/zcs.py @@ -189,7 +189,7 @@ class ZCS(_BaseInstrument): -------- Construct a curve to price the example. - """ + """ # noqa: E501 _rate_scalar = 1.0 diff --git a/python/rateslib/legs/components/custom.py b/python/rateslib/legs/components/custom.py index 2f4e4f4b..c7432aee 100644 --- a/python/rateslib/legs/components/custom.py +++ b/python/rateslib/legs/components/custom.py @@ -11,17 +11,9 @@ class CustomLeg(_BaseLeg): """ - Create a leg contained of user specified ``Periods``. + Create a leg containing user specified :class:`~rateslib.periods.components._BasePeriod`. - Useful for crafting amortising swaps with custom notional and date schedules. - - Parameters - ---------- - periods : iterable of _BasePeriod - A sequence of *Periods* to attach to the leg. - - Examples - -------- + .. rubric:: Examples .. ipython:: python :suppress: @@ -52,6 +44,11 @@ class CustomLeg(_BaseLeg): custom_leg = CustomLeg(periods=[fp1, fp2]) custom_leg.cashflows() + Parameters + ---------- + periods : iterable of _BasePeriod + A sequence of *Periods* to attach to the leg. + """ # noqa: E501 @property diff --git a/python/rateslib/periods/base.py b/python/rateslib/periods/base.py index 9318c6f2..c42eb34d 100644 --- a/python/rateslib/periods/base.py +++ b/python/rateslib/periods/base.py @@ -164,26 +164,6 @@ def analytic_delta( ------- float, Dual, Dual2 - Examples - -------- - .. ipython:: python - - curve = Curve({dt(2021,1,1): 1.00, dt(2025,1,1): 0.83}, interpolation="log_linear", id="SONIA") - fxr = FXRates({"gbpusd": 1.25}, base="usd") - - .. ipython:: python - - period = FixedPeriod( - start=dt(2022, 1, 1), - end=dt(2022, 7, 1), - payment=dt(2022, 7, 1), - frequency="S", - currency="gbp", - fixed_rate=4.00, - ) - period.analytic_delta(curve, curve) - period.analytic_delta(curve, curve, fxr) - period.analytic_delta(curve, curve, fxr, "gbp") """ # noqa: E501 disc_curve_: _BaseCurve = _disc_required_maybe_from_curve(curve, disc_curve) fx_, _ = _get_fx_and_base(self.currency, fx, base) @@ -224,11 +204,6 @@ def cashflows( ------- dict - Examples - -------- - .. ipython:: python - - period.cashflows(curve, curve, fxr) """ disc_curve_: _BaseCurve_ = _disc_maybe_from_curve(curve, disc_curve) if isinstance(disc_curve_, NoInput): @@ -318,14 +293,6 @@ def npv( ------- float, Dual, Dual2, or dict of such - Examples - -------- - .. ipython:: python - - period.npv(curve, curve) - period.npv(curve, curve, fxr) - period.npv(curve, curve, fxr, "gbp") - period.npv(curve, curve, fxr, local=True) """ pass # pragma: no cover diff --git a/python/tests/instruments/test_instruments_legacy.py b/python/tests/instruments/test_instruments_legacy.py index a779c93a..59b3c08e 100644 --- a/python/tests/instruments/test_instruments_legacy.py +++ b/python/tests/instruments/test_instruments_legacy.py @@ -33,7 +33,7 @@ FXRiskReversal, FXStraddle, FXStrangle, - FXSwap, + # FXSwap, IndexFixedRateBond, # Portfolio, # Spread, @@ -51,7 +51,8 @@ ZCIS, ZCS, Fly, - FXExchange, + FXForward, + FXSwap, FXVolValue, Portfolio, Spread, @@ -170,12 +171,11 @@ def simple_solver(): FXSwap( dt(2022, 7, 1), "3M", - currency="usd", - leg2_currency="eur", + pair="usdeur", curves=["usdusd", "usdusd", "eureur", "eureur"], notional=-1e6, ), - FXExchange( + FXForward( settlement=dt(2022, 10, 1), pair="eurusd", curves=[None, "eureur", None, "usdusd"], @@ -680,15 +680,14 @@ class TestNullPricing: FXSwap( dt(2022, 7, 1), "3M", - currency="usd", - leg2_currency="eur", - curves=["usdusd", "usdusd", "eureur", "eureur"], - notional=-1e6, + pair="usdeur", + curves=["usdusd", "eurusd"], + notional=1e6, # fx_fixing=0.999851, # split_notional=1003052.812, # points=2.523505, ), - FXExchange( + FXForward( settlement=dt(2022, 10, 1), pair="eurusd", curves=[None, "eureur", None, "usdusd"], @@ -796,7 +795,7 @@ def test_null_priced_delta_xcs_float_spread(self, inst) -> None: fx=fxf, ) result = inst.delta(solver=solver) - rate = inst.rate(solver=solver) + # rate = inst.rate(solver=solver) assert abs(result.iloc[0, 0] - 25.0) < 1.0 result2 = inst.npv(solver=solver) assert abs(result2) < 1e-3 @@ -920,7 +919,7 @@ def test_null_priced_gamma2(self, inst) -> None: "fixed_rate", ), ( - FXExchange( + FXForward( dt(2022, 3, 1), pair="usdeur", curves=[NoInput(0), "usdusd", NoInput(0), "eurusd"], @@ -973,8 +972,7 @@ def test_null_priced_delta_round_trip_one_pricing_param(self, inst, param) -> No FXSwap( dt(2022, 2, 1), "3M", - currency="eur", - leg2_currency="usd", + pair="eurusd", curves=[NoInput(0), "eurusd", NoInput(0), "usdusd"], ), "points", @@ -1120,14 +1118,13 @@ def test_null_priced_delta_round_trip_one_pricing_param_fx_fix(self, inst, param FXSwap( dt(2022, 7, 1), "3M", - currency="usd", - leg2_currency="eur", + pair="usdeur", notional=-1e6, # fx_fixing=0.999851, # split_notional=1003052.812, # points=2.523505, ), - FXExchange( + FXForward( settlement=dt(2022, 10, 1), pair="usdeur", notional=-1e6 * 25 / 74.27, @@ -2057,9 +2054,9 @@ def test_value_raise(self, curve) -> None: Value(effective=dt(2022, 7, 1), metric="bad").rate(curves=curve) -class TestFXExchange: +class TestFXForward: def test_cashflows(self) -> None: - fxe = FXExchange( + fxe = FXForward( settlement=dt(2022, 10, 1), pair="eurusd", notional=-1e6, @@ -2088,7 +2085,7 @@ def test_cashflows(self) -> None: ], ) def test_npv_at_mid_market(self, curve, curve2, base, fx) -> None: - fxe = FXExchange( + fxe = FXForward( settlement=dt(2022, 3, 1), pair="eurusd", fx_rate=1.2080131682341035, @@ -2102,7 +2099,7 @@ def test_npv_at_mid_market(self, curve, curve2, base, fx) -> None: assert abs(result - 0.0) < 1e-8 def test_rate(self, curve, curve2) -> None: - fxe = FXExchange( + fxe = FXForward( settlement=dt(2022, 3, 1), pair="eurusd", fx_rate=1.2080131682341035, @@ -2116,7 +2113,7 @@ def test_rate(self, curve, curve2) -> None: def test_npv_fx_numeric(self, curve) -> None: # This demonstrates the ambiguity and poor practice of # using numeric fx as pricing input, although it will return. - fxe = FXExchange( + fxe = FXForward( settlement=dt(2022, 3, 1), pair="eurusd", fx_rate=1.2080131682341035, @@ -2130,7 +2127,7 @@ def test_npv_fx_numeric(self, curve) -> None: fxe.npv(curves=[curve] * 4, fx=2.0, base="bad") def test_npv_no_fx_raises(self, curve) -> None: - fxe = FXExchange( + fxe = FXForward( settlement=dt(2022, 3, 1), pair="eurusd", fx_rate=1.2080131682341035, @@ -2142,8 +2139,8 @@ def test_npv_no_fx_raises(self, curve) -> None: fxe.npv(curves=[curve, curve]) def test_notional_direction(self, curve, curve2) -> None: - fx1 = FXExchange(notional=1e6, pair="eurusd", settlement=dt(2022, 1, 1), fx_rate=1.20) - fx2 = FXExchange(notional=-1e6, pair="eurusd", settlement=dt(2022, 1, 1), fx_rate=1.30) + fx1 = FXForward(notional=1e6, pair="eurusd", settlement=dt(2022, 1, 1), fx_rate=1.20) + fx2 = FXForward(notional=-1e6, pair="eurusd", settlement=dt(2022, 1, 1), fx_rate=1.30) pf = Portfolio([fx1, fx2]) fx = FXRates({"eurusd": 1.30}, base="usd") result = pf.npv(curves=[None, curve, None, curve2], fx=fx) @@ -2154,7 +2151,7 @@ def test_notional_direction(self, curve, curve2) -> None: assert abs(result - expected) < 1e-8 def test_analytic_delta_is_zero(self, curve, curve2) -> None: - result = FXExchange( + result = FXForward( settlement=dt(2022, 3, 1), pair="eurusd", fx_rate=1.2080131682341035, @@ -2171,7 +2168,7 @@ def test_error_msg_for_no_fx(self) -> None: instruments=[ IRS(dt(2024, 6, 24), "3m", spec="eur_irs", curves=eur), IRS(dt(2024, 6, 24), "3m", spec="usd_irs", curves=usd), - FXExchange( + FXForward( pair="eurusd", settlement=dt(2024, 9, 24), curves=[None, eurusd, None, usd], @@ -2180,6 +2177,20 @@ def test_error_msg_for_no_fx(self) -> None: s=[3.77, 5.51, 1.0775], ) + def test_leg2_notional(self, curve, curve2) -> None: + fx1 = FXForward( + leg2_notional=-1.2e6, pair="eurusd", settlement=dt(2022, 1, 1), fx_rate=1.20 + ) + fx2 = FXForward(leg2_notional=1.3e6, pair="eurusd", settlement=dt(2022, 1, 1), fx_rate=1.30) + pf = Portfolio([fx1, fx2]) + fx = FXRates({"eurusd": 1.30}, base="usd") + result = pf.npv(curves=[None, curve, None, curve2], fx=fx) + expected = 100000.0 + assert abs(result - expected) < 1e-8 + result = pf.npv(curves=[None, curve, None, curve2], fx=fx, base="eur") + expected = 100000.0 / 1.30 + assert abs(result - expected) < 1e-8 + class TestNDF: def test_construction(self) -> None: @@ -2616,7 +2627,7 @@ def test_nonmtm_fx_fixing(self, curve, curve2, fix) -> None: notional=10e6, leg2_fx_fixings=mapping[fix], ) - assert abs(xcs.npv(curves=[curve, curve, curve2, curve2], fx=fxr)) < 1e-7 + assert abs(xcs.npv(curves=[curve, curve, curve2, curve2], fx=fxf)) < 1e-7 def test_nonmtm_fx_fixing_raises_type_crossing(self, curve, curve2): fxr = FXRates({"usdnok": 10}, settlement=dt(2022, 1, 1)) @@ -2697,7 +2708,7 @@ def test_npv_fx_as_float_raises(self) -> None: ) curve = Curve({dt(2022, 2, 1): 1.0, dt(2024, 2, 1): 0.9}) with pytest.raises(AttributeError, match="'float' object has no attribute 'rate'"): - result = xcs.npv(curves=[curve] * 2, fx=10.0) + xcs.npv(curves=[curve] * 2, fx=10.0) @pytest.mark.skip(reason="v2.5 uses FXForwards as a more explicit input type.") def test_npv_fx_as_rates_valid(self) -> None: @@ -3045,7 +3056,7 @@ def test_nonmtmfixxcs_fx_fixing(self, curve, curve2, fix) -> None: leg2_fx_fixings=mapping[fix], leg2_fixed_rate=2.0, ) - assert abs(xcs.npv(curves=[curve2, curve2, curve, curve], fx=fxr)) < 1e-7 + assert abs(xcs.npv(curves=[curve2, curve2, curve, curve], fx=fxf)) < 1e-7 def test_nonmtmfixxcs_fx_fixing_type_crossing_raises(self, curve, curve2) -> None: fxr = FXRates({"usdnok": 10}, settlement=dt(2022, 1, 1)) @@ -3986,13 +3997,11 @@ def test_fxswap_rate(self, curve, curve2) -> None: fxs = FXSwap( dt(2022, 2, 1), "8M", - currency="usd", - leg2_currency="nok", - payment_lag=0, + pair="usdnok", notional=1e6, ) expected = fxf.swap("usdnok", [dt(2022, 2, 1), dt(2022, 10, 1)]) - result = fxs.rate([NoInput(0), curve, NoInput(0), curve2], NoInput(0), fxf) + result = fxs.rate(curves=[NoInput(0), curve, NoInput(0), curve2], fx=fxf) assert abs(result - expected) < 1e-10 assert np.isclose(result.dual, expected.dual) @@ -4005,22 +4014,21 @@ def test_fxswap_pair_arg(self, curve, curve2) -> None: dt(2022, 2, 1), "8M", pair="usdnok", - payment_lag=0, notional=1e6, ) expected = fxf.swap("usdnok", [dt(2022, 2, 1), dt(2022, 10, 1)]) - result = fxs.rate([NoInput(0), curve, NoInput(0), curve2], NoInput(0), fxf) + result = fxs.rate(curves=[NoInput(0), curve, NoInput(0), curve2], fx=fxf) assert abs(result - expected) < 1e-10 assert np.isclose(result.dual, expected.dual) def test_currency_arg_pair_overlap(self) -> None: - fxs = FXSwap( - dt(2022, 2, 1), - "8M", - pair="usdnok", - currency="jpy", - ) - assert fxs.leg1.currency == "usd" + with pytest.raises(TypeError, match="unexpected keyword argument 'currency'"): + FXSwap( + dt(2022, 2, 1), + "8M", + pair="usdnok", + currency="jpy", + ) def test_fxswap_npv(self, curve, curve2) -> None: fxf = FXForwards( @@ -4030,92 +4038,63 @@ def test_fxswap_npv(self, curve, curve2) -> None: fxs = FXSwap( dt(2022, 2, 1), "8M", - currency="usd", - leg2_currency="nok", - payment_lag=0, + pair="usdnok", notional=1e6, ) - assert abs(fxs.npv([NoInput(0), curve, NoInput(0), curve2], NoInput(0), fxf)) < 1e-7 + assert abs(fxs.npv(curves=[NoInput(0), curve, NoInput(0), curve2], fx=fxf)) < 1e-7 - result = fxs.rate([NoInput(0), curve, NoInput(0), curve2], NoInput(0), fxf, fixed_rate=True) + result = fxs.rate(curves=[NoInput(0), curve, NoInput(0), curve2], fx=fxf) fxs.leg2_fixed_rate = result - assert abs(fxs.npv([NoInput(0), curve, NoInput(0), curve2], NoInput(0), fxf)) < 1e-7 - - @pytest.mark.parametrize( - ("points", "split_notional"), - [(100, 1e6), (NoInput(0), 1e6), (100, NoInput(0))], - ) - def test_fxswap_points_raises(self, points, split_notional) -> None: - if points is not NoInput(0): - msg = "Cannot initialise FXSwap with `points` but without `fx_fixings`." - with pytest.raises(ValueError, match=msg): - FXSwap( - dt(2022, 2, 1), - "8M", - currency="usd", - leg2_currency="nok", - payment_lag=0, - notional=1e6, - split_notional=split_notional, - points=points, - ) - else: - msg = "Cannot initialise FXSwap with `split_notional` but without `fx_fixings`" - with pytest.raises(ValueError, match=msg): - FXSwap( - dt(2022, 2, 1), - "8M", - currency="usd", - leg2_currency="nok", - payment_lag=0, - notional=1e6, - split_notional=split_notional, - points=points, - ) + assert abs(fxs.npv(curves=[NoInput(0), curve, NoInput(0), curve2], fx=fxf)) < 1e-7 - def test_fxswap_points_warns(self) -> None: - with pytest.warns(UserWarning): - fxs = FXSwap( + def test_fxswap_points_raises(self) -> None: + msg = "`points` has been set on an FXSwap without a defined `fx_fixings`" + with pytest.raises(ValueError, match=msg): + FXSwap( dt(2022, 2, 1), "8M", - fx_fixings=11.0, - currency="usd", - leg2_currency="nok", - payment_lag=0, + pair="usdnok", notional=1e6, + points=100.0, ) - assert fxs._is_split is False - with pytest.warns(UserWarning): - fxs = FXSwap( + def test_fxswap_points_warns(self) -> None: + with pytest.raises(ValueError, match="An FXSwap must set ``fx_fixings`` and ``points`` si"): + FXSwap( dt(2022, 2, 1), "8M", - fx_fixings=11.0, - currency="usd", - leg2_currency="nok", - payment_lag=0, + leg2_fx_fixings=11.0, + pair="usdnok", notional=1e6, - split_notional=1e6, ) - assert fxs._is_split is True + + FXSwap( + dt(2022, 2, 1), + "8M", + leg2_fx_fixings=11.0, + points=1000.0, + pair="usdnok", + notional=1e6, + split_notional=1e6, + ) @pytest.mark.parametrize( ("fx_fixings", "points", "split_notional", "expected"), [ (NoInput(0), NoInput(0), NoInput(0), Dual(0, ["fx_usdnok"], [-1712.833785])), - (11.0, 1800.0, NoInput(0), Dual(-3734.617680, ["fx_usdnok"], [3027.88203904])), + (11.0, 1800.0, NoInput(0), Dual(3734.617680, ["fx_usdnok"], [-3027.88203904])), ( 11.0, 1754.5623360395632, NoInput(0), - Dual(-4166.37288388, ["fx_usdnok"], [3071.05755945]), + Dual(4166.37288388, ["fx_usdnok"], [-3071.05755945]), ), ( 10.032766762996951, 1754.5623360395632, NoInput(0), - Dual(0, ["fx_usdnok"], [2654.42027107]), + Dual(0, ["fx_usdnok"], [-2654.42027107]), ), ( 10.032766762996951, @@ -4151,20 +4130,63 @@ def test_fxswap_parameter_combinations_off_mids_given( fxs = FXSwap( dt(2022, 2, 1), "8M", - fx_fixings=fx_fixings, + leg2_fx_fixings=fx_fixings, points=points, split_notional=split_notional, - currency="usd", - leg2_currency="nok", - payment_lag=0, + pair="usdnok", notional=1e6, ) - assert fxs.points == points + assert fxs.kwargs.meta["points"] == points result = fxs.npv(curves=[NoInput(0), curve, NoInput(0), curve2], fx=fxf, base="usd") + # rate = fxs.rate(curves=[curve, curve2], fx=fxf) assert abs(result - expected) < 1e-6 assert np.isclose(result.dual, expected.dual) + def test_direction_fx_swap_notional(self, curve, curve2): + fxf = FXForwards( + FXRates({"usdnok": 10}, settlement=dt(2022, 1, 3)), + {"usdusd": curve, "nokusd": curve2, "noknok": curve2}, + ) + fxs = FXSwap( + dt(2022, 2, 1), + "8M", + leg2_fx_fixings=10.0, + points=1000.0, + pair="usdnok", + notional=1e6, + ) + fxs2 = FXSwap( + dt(2022, 2, 1), + "8M", + leg2_fx_fixings=10.0, + points=1500.0, + pair="usdnok", + notional=-1e6, + ) + assert ( + fxs.npv(curves=[curve, curve2], fx=fxf) - fxs2.npv(curves=[curve, curve2], fx=fxf) + ) > 0 + + @pytest.mark.parametrize("leg", [1, 2]) + def test_notional_directions_with_split_notional(self, leg): + fxs = FXSwap( + **{ + "effective": dt(2022, 2, 1), + "termination": dt(2022, 4, 1), + f"{'leg2_' if leg == 2 else ''}notional": 1e6, + "split_notional": 1e6, + "pair": "usdnok", + } + ) + l1c1_sign = fxs.leg1.periods[0].settlement_params.notional < 0 + l1c2_sign = fxs.leg1.periods[1].settlement_params.notional < 0 + l2c1_sign = fxs.leg2.periods[0].settlement_params.notional < 0 + l2c2_sign = fxs.leg2.periods[1].settlement_params.notional < 0 + assert l1c1_sign != l1c2_sign + assert l2c1_sign != l2c2_sign + assert l1c1_sign != l2c1_sign + def test_rate_with_fixed_parameters(self, curve, curve2) -> None: fxf = FXForwards( FXRates({"usdnok": 10}, settlement=dt(2022, 1, 3)), @@ -4173,15 +4195,13 @@ def test_rate_with_fixed_parameters(self, curve, curve2) -> None: fxs = FXSwap( dt(2022, 2, 1), "8M", - fx_fixings=10.01, + leg2_fx_fixings=10.01, points=1765, split_notional=1.01e6, - currency="usd", - leg2_currency="nok", - payment_lag=0, + pair="usdnok", notional=1e6, ) - result = fxs.rate([NoInput(0), curve, NoInput(0), curve2], fx=fxf) + result = fxs.rate(curves=[NoInput(0), curve, NoInput(0), curve2], fx=fxf) expected = 1746.59802 assert abs(result - expected) < 1e-4 @@ -4211,14 +4231,18 @@ def test_transition_from_dual_to_dual2(self, curve, curve2) -> None: fxs = FXSwap( dt(2022, 2, 1), "8M", - currency="usd", - leg2_currency="nok", - payment_lag=0, + pair="usdnok", notional=1e6, ) - fxs.npv(curves=[None, fxf.curve("usd", "usd"), None, fxf.curve("nok", "usd")], fx=fxf) + result = fxs.npv( + curves=[None, fxf.curve("usd", "usd"), None, fxf.curve("nok", "usd")], fx=fxf + ) + assert isinstance(result, Dual) fxf._set_ad_order(2) - fxs.npv(curves=[None, fxf.curve("usd", "usd"), None, fxf.curve("nok", "usd")], fx=fxf) + result2 = fxs.npv( + curves=[None, fxf.curve("usd", "usd"), None, fxf.curve("nok", "usd")], fx=fxf + ) + assert isinstance(result2, Dual2) def test_transition_from_dual_to_dual2_rate(self, curve, curve2) -> None: # Test added for BUG, see PR: XXX @@ -4230,21 +4254,64 @@ def test_transition_from_dual_to_dual2_rate(self, curve, curve2) -> None: fxs = FXSwap( dt(2022, 2, 1), "8M", - currency="usd", - leg2_currency="nok", - payment_lag=0, + pair="usdnok", notional=1e6, ) - fxs.rate(curves=[None, fxf.curve("usd", "usd"), None, fxf.curve("nok", "usd")], fx=fxf) + result = fxs.rate( + curves=[None, fxf.curve("usd", "usd"), None, fxf.curve("nok", "usd")], fx=fxf + ) + assert isinstance(result, Dual) fxf._set_ad_order(2) - fxs.rate(curves=[None, fxf.curve("usd", "usd"), None, fxf.curve("nok", "usd")], fx=fxf) + result = fxs.rate( + curves=[None, fxf.curve("usd", "usd"), None, fxf.curve("nok", "usd")], fx=fxf + ) + assert isinstance(result, Dual2) + @pytest.mark.skip(reason="in v2.5 split notional is not the default and must be set directly") def test_split_notional_raises(self): # this is an unpriced FXswap with split notional fxs = FXSwap(effective=dt(2022, 2, 1), termination="3m", pair="eurusd") - with pytest.raises(ValueError, match="A `curve` is required to determine a `split_notion"): + with pytest.raises( + TypeError, match="`curves` have not been supplied correctly. A `disc_curve` is required" + ): fxs.rate() + @pytest.mark.parametrize( + ("eom", "expected"), + [ + (False, dt(2022, 5, 28)), + (True, dt(2022, 5, 31)), + ], + ) + def test_eom_dates(self, eom, expected): + fxs = FXSwap( + effective=dt(2022, 2, 28), + termination="3m", + pair="eurusd", + calendar="all", + modifier="mf", + eom=eom, + ) + assert fxs.kwargs.leg1["termination"] == expected + + @pytest.mark.parametrize( + ("roll", "expected"), + [ + ("imm", dt(2022, 4, 20)), + (19, dt(2022, 4, 19)), + ], + ) + def test_roll_dates(self, roll, expected): + fxs = FXSwap( + effective=dt(2022, 1, 19), + termination="3m", + pair="eurusd", + calendar="all", + modifier="mf", + roll=roll, + ) + assert fxs.kwargs.leg1["termination"] == expected + class TestSTIRFuture: def test_stir_rate(self, curve, curve2) -> None: @@ -5125,7 +5192,7 @@ def test_xcs(self) -> None: ), ), ( - FXExchange( + FXForward( dt(2022, 1, 15), pair="eurusd", curves=["eureur", "eureur", "usdusd", "usdeur"], @@ -5180,8 +5247,7 @@ def test_xcs(self) -> None: FXSwap( dt(2022, 1, 5), "3M", - currency="eur", - leg2_currency="usd", + pair="eurusd", curves=["eureur", "eurusd", "usdusd", "usdusd"], ), DataFrame( @@ -5849,7 +5915,7 @@ def test_metric_and_period_metric_compatible(self) -> None: instruments=[ IRS(dt(2024, 6, 24), "3m", spec="eur_irs", curves=eur), IRS(dt(2024, 6, 24), "3m", spec="usd_irs", curves=usd), - FXExchange( + FXForward( pair="eurusd", settlement=dt(2024, 9, 24), curves=[None, eurusd, None, usd],