From deca7c962692ae2ee81e5a92c4484f2bf253a48d Mon Sep 17 00:00:00 2001 From: "JHM Darbyshire (iMac)" Date: Tue, 8 Aug 2023 20:21:06 +0200 Subject: [PATCH] DOC: add FX explanations and fix tests --- docs/source/g_fx.rst | 206 -------------------- docs/source/g_mechanisms.rst | 360 ++++++++++++++++++++++++++++++----- docs/source/i_api.rst | 1 + rateslib/_swaptions.py | 75 ++++++++ rateslib/instruments.py | 28 ++- rateslib/periods.py | 9 +- tests/test_instruments.py | 190 +++++++++--------- tests/test_solver.py | 4 +- 8 files changed, 525 insertions(+), 348 deletions(-) create mode 100644 rateslib/_swaptions.py diff --git a/docs/source/g_fx.rst b/docs/source/g_fx.rst index 5da796c5..0705502a 100644 --- a/docs/source/g_fx.rst +++ b/docs/source/g_fx.rst @@ -36,209 +36,3 @@ proceeding to review the documentation for :ref:`FXForwards`. f_fxr.rst f_fxf.rst - - -When we value NPV, what is `base`? -------------------------------------- - -One of the most important aspects to keep track of when valuing -:meth:`Instrument.npv()` is that -of the currency in which it is displayed. This is the ``base`` -currency it is displayed in. - -In order to provide a flexible, but minimal, UI *base* does not need to -be explicitly set to get the results one expects. The arguments needed for the -*npv* method are: - - ``curves``, ``solver``, ``fx``, ``base``, ``local`` - -All of these arguments are optional since one might typically be inferred -from another. This creates some complexity particularly when *base* is -not given and it might be inferred from others, or when *base* is given -but it conflicts with the *base* associated with other objects. - -**The local argument** - -``local`` can, at any time, be set to *True* and this will return a dict -containing a currency key and a value. By using this we keep track -of the currency of each *Leg* of the *Instrument*. This is important for -risk sensitivities and is used internally, especially for multi-currency instruments. - -.. ipython:: python - - curve = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 0.96}, id="curve") - fxr = FXRates({"usdeur": 0.9, "gbpusd": 1.25}, base="gbp", settlement=dt(2022, 1, 3)) - fxf = FXForwards( - fx_rates=fxr, - fx_curves={"usdusd": curve, "eureur": curve, "gbpgbp": curve, "eurusd": curve, "gbpusd": curve}, - base="eur", - ) - solver = Solver( - curves=[curve], - instruments=[IRS(dt(2022, 1, 1), "1y", "a", curves=curve)], - s=[4.109589041095898], - fx=fxf, - ) - -.. ipython:: python - - nxcs = NonMtmXCS(dt(2022, 2, 1), "6M", "A", currency="eur", leg2_currency="usd") - nxcs.npv(curves=[curve]*4, fx=fxf, local=True) - nxcs.npv(curves=[curve]*4, fx=fxf, base="usd") - - -Best Practice -*************** - -If you want to return an *npv* value in local currency (or in *Leg1* currency for multi-currency instruments), -then you do **not** need to supply ``base`` or ``fx`` arguments. However, to be explicit, -*base* can also be specified. - -.. ipython:: python - - irs = IRS(dt(2022, 2, 1), "6M", "A", currency="usd", fixed_rate=4.0, curves=curve) - irs.npv(solver=solver) # USD is local currency default, solver.fx.base is EUR. - irs.npv(solver=solver, base="usd") # USD is explicit, solver.fx.base is EUR. - -To calculate a value in another non-local currency supply an ``fx`` object and -specify the ``base``. It is **not** good practice to supply ``fx`` as numeric since this -can result in errors (if the exchange rate is given the wrong way round (human error)) -and it does not preserve AD or any FX sensitivities. *base* is inferred from the -*fx* object so the following are all equivalent. - -.. ipython:: python - - irs.npv(fx=fxr) # GBP is fx's base currency - irs.npv(fx=fxr, base="gbp") # GBP is explicitly specified - irs.npv(fx=fxr, base=fxr.base) # GBP is fx's base currency - -Technical rules -**************** - -If ``base`` is not given it will be inferred from one of two objects; - -- either it will be inferred from the provided ``fx`` object, -- or it will be inferred from the *Leg* or from *Leg1* of an *Instrument*. - -``base`` will **not** be inherited from a second layer inherited object. I.e. ``base`` -will not be set equal to the base currency of the ``solver.fx`` associated object. - -.. image:: _static/base_inherit.png - :alt: Inheritance map for base - :width: 350 - -.. list-table:: Possible argument combinations supplied and rateslib return. - :widths: 66 5 5 12 12 - :header-rows: 1 - - * - **Case and Output** - - ``base`` - - ``fx`` - - ``solver`` with *fx* - - ``solver`` without *fx* - * - ``base`` **is explicit** - - - - - - - - - * - Returns if *currency* and ``base`` are available in ``fx`` object, otherwise - raises. - - X - - X - - - - - * - Returns and warns about best practice. - - X - - (numeric) - - - - - * - Returns if *currency* and ``base`` are available in ``fx`` object, otherwise - raises. - - X - - - - X - - - * - Returns if *currency* and ``base`` are available in ``fx`` object, otherwise - raises. Will warn if ``fx`` and ``solver.fx`` are not the same object. - - X - - X - - X - - - * - Returns if ``base`` aligns with local currency, else raises. - - X - - - - - - - * - Returns if ``base`` aligns with local currency, else raises. - - X - - - - - - X - * - ``base`` **is inferred** and logic reverts to above cases. - - - - - - - - - * - Returns inferring ``base`` from ``fx`` object. - - <- - - X - - - - - * - Returns inferring ``base`` from ``fx`` object. Warns if ``fx`` and - ``solver.fx`` are not the same object. - - <- - - X - - X - - - * - Returns inferring ``base`` from ``fx`` object. - - <- - - X - - - - X - * - Returns inferring ``base`` as *Leg* or *Leg1* local currency. - - (local) - - - - X - - - * - Returns inferring ``base`` as *Leg* or *Leg1* local currency. - - (local) - - - - - - X - * - Returns inferring ``base`` as *Leg* or *Leg1* local currency. - - (local) - - - - - - - -Examples -********** - -We continue the examples above using the USD IRS created and consider possible *npvs*: - -.. ipython:: python - - def npv(irs, curves=None, solver=None, fx=None, base=None): - try: - _ = irs.npv(curves, solver, fx, base) - except Exception as e: - _ = str(e) - return _ - -.. ipython:: python - :okwarning: - - # The following are all explicit EUR output - npv(irs, base="eur") # Error since no conversion rate available. - npv(irs, base="eur", fx=fxr) # Takes 0.9 FX rate from object. - npv(irs, base="eur", fx=2.0) # UserWarning and no fx Dual sensitivities. - npv(irs, base="eur", solver=solver) # Takes 0.95 FX rates from solver.fx - npv(irs, base="eur", fx=fxr, solver=solver) # Takes 0.9 FX rate from fx - - # The following infer the base - npv(irs) # Base is inferred as local currency: USD - npv(irs, fx=fxr) # Base is inferred from fx: GBP - npv(irs, fx=fxr, base=fxr.base) # Base is explicit from fx: GBP - npv(irs, fx=fxr, solver=solver) # Base is inferred from fx: GBP. UserWarning for different fx objects - npv(irs, solver=solver) # Base is inferred as local currency: USD - npv(irs, solver=solver, fx=solver.fx) # Base is inferred from solver.fx: EUR diff --git a/docs/source/g_mechanisms.rst b/docs/source/g_mechanisms.rst index 879c8d91..75c643f9 100644 --- a/docs/source/g_mechanisms.rst +++ b/docs/source/g_mechanisms.rst @@ -4,63 +4,325 @@ Pricing Mechanisms ****************** +This guide is aimed at users who are not completely new to rateslib and who have a little +experience already building *Instruments*, *Curves* and *Solvers* are are familiar with some +of its basic mechanics already. + Summary ************************** *Rateslib's* API design for valuing and obtaining risk sensitivities of *Instruments* follows the first two :ref:`pillars of its design philosophy:` -1) Maximise flexibility : minimise user input, -2) Prioritise risk sensitivities above valuation. +- Maximise flexibility : minimise user input, +- Prioritise risk sensitivities above valuation. This means the arguments required for the :meth:`Instrument.npv()`, :meth:`Instrument.delta()` and :meth:`Instrument.gamma()` +are the same and optionally require: + +``curves``, ``solver``, ``fx``, ``base``, ``local`` + +When calculating risk metrics a ``solver``, which contains derivative mapping information, is +required. However, when calculating value, it is sufficient to just provide ``curves``. In this +case, and if the *curves* do not contain AD then the calculation might be upto 300% faster. + +Since these arguments are optional and can be inferred from each other it is important to +understand the combination that can produce results. There are two section which discuss these +combiantions. + +1) How ``solver``, ``fx``, ``base`` and ``local`` interact? +2) How ``curves``, ``solver`` and *Instruments* interact? + +.. _base-fx-doc: + +How do ``solver``, ``fx``, ``base`` and ``local`` interact? +************************************************************* + +One of the most important aspects to keep track of when valuing +:meth:`Instrument.npv()` is that +of the currency in which it is displayed. This is the ``base`` +currency it is displayed in. *base* does not need to +be explicitly set to get the results one expects. + +**The local argument** + +``local`` can, at any time, be set to *True* and this will return a dict +containing a currency key and a value. By using this we keep track +of the currency of each *Leg* of the *Instrument*. This is important for +risk sensitivities and is used internally, especially for multi-currency instruments. + +.. ipython:: python + + curve = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 0.96}, id="curve") + fxr = FXRates({"usdeur": 0.9, "gbpusd": 1.25}, base="gbp", settlement=dt(2022, 1, 3)) + fxf = FXForwards( + fx_rates=fxr, + fx_curves={"usdusd": curve, "eureur": curve, "gbpgbp": curve, "eurusd": curve, "gbpusd": curve}, + base="eur", + ) + solver = Solver( + curves=[curve], + instruments=[IRS(dt(2022, 1, 1), "1y", "a", curves=curve)], + s=[4.109589041095898], + fx=fxf, + ) + +.. ipython:: python + + nxcs = NonMtmXCS(dt(2022, 2, 1), "6M", "A", currency="eur", leg2_currency="usd") + nxcs.npv(curves=[curve]*4, fx=fxf, local=True) + nxcs.npv(curves=[curve]*4, fx=fxf, base="usd") + +If the ``local`` argument is *False* then *npv* will be returned in only one single currency. +For multi-currency instruments this means at least one leg will be converted into the *base* +currency. + +What is best practice? +------------------------ + +If you want to return an *npv* value in local currency (or in *Leg1* currency for multi-currency +instruments), then you do **not** need to supply ``base`` or ``fx`` arguments. However, to +be explicit, *base* can also be specified. + +.. ipython:: python + + irs = IRS(dt(2022, 2, 1), "6M", "A", currency="usd", fixed_rate=4.0, curves=curve) + irs.npv(solver=solver) # USD is local currency default, solver.fx.base is EUR. + irs.npv(solver=solver, base="usd") # USD is explicit, solver.fx.base is EUR. + +To calculate a value in another non-local currency supply an ``fx`` object and +specify the ``base``. It is **not** good practice to supply *fx* as numeric since this +can result in errors (if the exchange rate is given the wrong way round (human error)) +and it does not preserve AD or any FX sensitivities. *base* is inferred from the +*fx* object so the following are all equivalent. *fx* objects are commonly inherited from +*solvers*. +.. ipython:: python + + irs.npv(fx=fxr) # GBP is fx's base currency + irs.npv(fx=fxr, base="gbp") # GBP is explicitly specified + irs.npv(fx=fxr, base=fxr.base) # GBP is fx's base currency + irs.npv(solver=solver, base="gbp") # GBP is explicitly specified + +Technical rules +----------------- + +If ``base`` is not given it will be inferred from one of two objects; + +- either it will be inferred from the provided ``fx`` object, +- or it will be inferred from the *Leg* or from *Leg1* of an *Instrument*. + +``base`` will **not** be inherited from a second layer inherited object. I.e. ``base`` +will not be set equal to the base currency of the ``solver.fx`` associated object. + +.. image:: _static/base_inherit.png + :alt: Inheritance map for base + :width: 350 + +.. list-table:: Possible argument combinations supplied and rateslib return. + :widths: 66 5 5 12 12 + :header-rows: 1 + + * - **Case and Output** + - ``base`` + - ``fx`` + - ``solver`` with *fx* + - ``solver`` without *fx* + * - ``base`` **is explicit** + - + - + - + - + * - Returns if *currency* and ``base`` are available in ``fx`` object, otherwise + raises. + - X + - X + - + - + * - Returns and warns about best practice. + - X + - (numeric) + - + - + * - Returns if *currency* and ``base`` are available in ``fx`` object, otherwise + raises. + - X + - + - X + - + * - Returns if *currency* and ``base`` are available in ``fx`` object, otherwise + raises. Will warn if ``fx`` and ``solver.fx`` are not the same object. + - X + - X + - X + - + * - Returns if ``base`` aligns with local currency, else raises. + - X + - + - + - + * - Returns if ``base`` aligns with local currency, else raises. + - X + - + - + - X + * - ``base`` **is inferred** and logic reverts to above cases. + - + - + - + - + * - Returns inferring ``base`` from ``fx`` object. + - <- + - X + - + - + * - Returns inferring ``base`` from ``fx`` object. Warns if ``fx`` and + ``solver.fx`` are not the same object. + - <- + - X + - X + - + * - Returns inferring ``base`` from ``fx`` object. + - <- + - X + - + - X + * - Returns inferring ``base`` as *Leg* or *Leg1* local currency. + - (local) + - + - X + - + * - Returns inferring ``base`` as *Leg* or *Leg1* local currency. + - (local) + - + - + - X + * - Returns inferring ``base`` as *Leg* or *Leg1* local currency. + - (local) + - + - + - + +Examples +---------- + +We continue the examples above using the USD IRS created and consider possible *npvs*: + +.. ipython:: python -The pricing mechanisms in ``rateslib`` require ``Instruments`` and -``Curves``. ``fx`` objects (usually ``FXForwards``) may also be required + def npv(irs, curves=None, solver=None, fx=None, base=None): + try: + _ = irs.npv(curves, solver, fx, base) + except Exception as e: + _ = str(e) + return _ + +.. ipython:: python + :okwarning: + + # The following are all explicit EUR output + npv(irs, base="eur") # Error since no conversion rate available. + npv(irs, base="eur", fx=fxr) # Takes 0.9 FX rate from object. + npv(irs, base="eur", fx=2.0) # UserWarning and no fx Dual sensitivities. + npv(irs, base="eur", solver=solver) # Takes 0.95 FX rates from solver.fx + npv(irs, base="eur", fx=fxr, solver=solver) # Takes 0.9 FX rate from fx + + # The following infer the base + npv(irs) # Base is inferred as local currency: USD + npv(irs, fx=fxr) # Base is inferred from fx: GBP + npv(irs, fx=fxr, base=fxr.base) # Base is explicit from fx: GBP + npv(irs, fx=fxr, solver=solver) # Base is inferred from fx: GBP. UserWarning for different fx objects + npv(irs, solver=solver) # Base is inferred as local currency: USD + npv(irs, solver=solver, fx=solver.fx) # Base is inferred from solver.fx: EUR + +.. _mechanisms-curves-doc: + +How ``curves``, ``solver`` and *Instruments* interact? +******************************************************** + +The pricing mechanisms in *rateslib* require :ref:`Instruments` and +:ref:`Curves`. :ref:`FX` objects +(usually :class:`FXForwards`) may also be required (for multi-currency instruments), and these -are all often interdependent and calibrated by a ``Solver``. +are all often interdependent and calibrated by a :ref:`Solver`. + +Since *Instruments* are separate objects to *Curves* and *Solvers*, when pricing them it requires +a mapping to link them all together. This leads to... + +**Three different modes of initialising an** *Instrument*: + +1) **Dynamic - Price Time Mapping**: this means an *Instrument* is initialised without any + ``curves`` and these must be provided later at price time, usually inside a function call. + + .. ipython:: python + + instrument = IRS(dt(2022, 1, 1), "10Y", "A", fixed_rate=2.5) + curve = Curve({dt(2022, 1, 1): 1.0, dt(2032, 1, 1): 0.85}) + instrument.npv(curves=curve) + instrument.rate(curves=curve) + +2) **Explicit - Immediate Mapping**: this means an *Instrument* is initialised + with ``curves`` and this object will be used if no *Curves* are provided at price time. + The *Curves* must already exist when initialising the *Instrument*. + + .. ipython:: python -A careful API structure has been created which allows different mechanisms for -argument input to promote maximum flexibility which may be useful for certain -circumstances. + curve = Curve({dt(2022, 1, 1): 1.0, dt(2032, 1, 1): 0.85}) + instrument = IRS(dt(2022, 1, 1), "10Y", "A", fixed_rate=2.5, curves=curve) + instrument.npv() + instrument.rate() -There are **three different modes of initialising an** ``Instrument``: +3) **Indirect - String** ``id`` **Mapping**: this means an *Instrument* is initialised + with ``curves`` that contain lookup information to collect the *Curves* at price time + from a ``solver``. -- **Without** specifying any pricing ``curves`` at initialisation: this requires - **dynamic** curve specification later, at price time. -- **With** specifying all pricing ``curves`` at initialisation: this requires curves to - exist as objects, either ``Curve`` or ``LineCurve`` objects. -- **Indirectly** specifying all pricing ``curves`` by known string ``id``. This does - not require any curves to pre-exist. + .. ipython:: python -At price time there are then also **three modes of pricing an** -``Instrument``. The signature of the ``rate``, ``npv`` and ``cashflows`` methods -contains the arguments; ``curves`` and ``solver``. + instrument = IRS(dt(2022, 1, 1), "10Y", "A", fixed_rate=2.5, curves="curve-id") + curve = Curve({dt(2022, 1, 1): 1.0, dt(2032, 1, 1): 0.85}, id="curve-id") + solver = Solver( + curves=[curve], + instruments=[IRS(dt(2022, 1, 1), "10Y", "A", curves=curve)], + s=[1.6151376354769178] + ) + instrument.npv(solver=solver) + instrument.rate(solver=solver) -- If ``curves`` are given dynamically these are used regardless of which initialisation - mode was used. The input here will **overwrite** the curves specified at - initialisation. -- If ``curves`` are not given dynamically then those ``curves`` provided at - initialisation will be used. +Then, for price time, this then also leads to the following cases... - - If they were provided as objects these are used directly. - - If they were provided as string ``id`` form, then a ``solver`` is required - from which the relevant curves will be extracted. +**Two modes of pricing an** *Instrument*: -If ``curves`` are given dynamically in combination with a ``solver``, and those curves -do not form part of the solver's iteration remit then depending upon the options, -errors or warnings might be raised or ignore. See XXXXX TODO +1) **Direct Curves Override**: if ``curves`` are given dynamically these are used regardless of + which initialisation mode was used for the *Instrument*. + .. ipython:: python -Best Practice -*************** + curve = Curve({dt(2022, 1, 1): 1.0, dt(2032, 1, 1): 0.85}) + irs = IRS(dt(2022, 1, 1), "10Y", "A", curves=curve) + other_curve = Curve({dt(2022, 1, 1): 1.0, dt(2032, 1, 1): 0.85}) + irs.npv(curves=other_curve) # other_curve overrides the initialised curve + irs.rate(curves=other_curve) # other_curve overrides the initialised curve -The recommended way of working within ``rateslib`` -is to initialise ``Instruments`` with a defined ``curves`` argument +2) **With Default Initialisation**: if ``curves`` at price time are not provided then those + specified at initialisation are used. + + a) **As Objects**: if *Curves* were specified these are used directly (see 2. above) + + b) **From String id with Solver**: if ``curves`` are not objects, but strings, then a ``solver`` + must be supplied to extract the *Curves* from (see 3. above). + +In the unusual combination that ``curves`` are given directly in combination with a ``solver``, +and those curves do not form part of the solver's curve collection, then depending upon the +rateslib options configured, then errors or warnings might be raised or this might be ignored. + +What is best practice? +----------------------- + +Amongst the variety of input pricing methods there is a recommended way of working. +This is to use method 3) and to initialise ``Instruments`` with a defined ``curves`` argument **as string** ``id`` s. This does not impede dynamic pricing if ``curves`` are constructed and supplied later directly to pricing methods. @@ -80,7 +342,8 @@ The ``curves`` attribute on the ``Instrument`` is instructive of its pricing int irs.curves At any point a ``Curve`` could be constructed and used for dynamic pricing, even if -its ``id`` does not match the instrument initialisation. +its ``id`` does not match the instrument initialisation. This is usually used in sampling or +scenario analysis. .. ipython:: python @@ -90,28 +353,28 @@ its ``id`` does not match the instrument initialisation. ) irs.rate(curve) -The above output would have resulted regardless of under which of the three -modes the ``Instrument`` was initialised under; without ``curves``, with ``curves``, or -using indirect ``id`` s. - Why is this best practice? --------------------------- The reasons that this is best practice are: - It provides more flexibility when working with multiple different curve models and - multiple :class:`~rateslib.solver.Solver` s. -- It provides more flexibility since only ``Instruments`` constructed in this manner + multiple :class:`~rateslib.solver.Solver` s. Instruments do not need to be re-initialised just + to extract alternate valuations or alternate risk sensitivities. +- It provides more flexibility since only *Instruments* constructed in this manner can be directly added to the :class:`~rateslib.instruments.Portfolio` class. It also extends the :class:`~rateslib.instruments.Spread` and - :class:`~rateslib.instruments.Fly` classes. + :class:`~rateslib.instruments.Fly` classes to allow *Instruments* which do not share the same + *Curves*. +- It removes the need to externally keep track of the necessary pricing curves needed for each + instrument created, which is often four curves for two legs. - It creates redundancy by avoiding programmatic errors when curves are overwritten and object oriented associations are silently broken, which can occur when using the other methods. -- It is anticipated that this mechanism is the one most future proofed when ``rateslib`` - is extended for server-client-api transfer via JSON. +- It is anticipated that this mechanism is the one most future proofed when *rateslib* + is extended for server-client-api transfer via JSON or otherwise. -Multiple curve model ``Solver`` s +Multiple curve model *Solvers* --------------------------------- Consider two different curve models, a **log-linear** one and a **log-cubic spline**, @@ -204,6 +467,15 @@ display the delta risks. irs.delta(solver=ll_solver) irs.delta(solver=lc_solver) +The programmatic errors avoided are as follows: + +.. ipython:: python + + try: + irs.delta(curves=ll_curve, solver=lc_solver) + except Exception as e: + print(e) + Using a ``Portfolio`` ---------------------- diff --git a/docs/source/i_api.rst b/docs/source/i_api.rst index 63b6c4f9..21cb16b1 100644 --- a/docs/source/i_api.rst +++ b/docs/source/i_api.rst @@ -38,6 +38,7 @@ Defaults :no-inheritance-diagram: :skip: plot :skip: BusinessDay + :skip: datetime Calendars --------- diff --git a/rateslib/_swaptions.py b/rateslib/_swaptions.py new file mode 100644 index 00000000..ecaa7083 --- /dev/null +++ b/rateslib/_swaptions.py @@ -0,0 +1,75 @@ +from math import pi, log, exp +from scipy.stats import norm + + +def swaption_price(forward, strike, expiry, ann_vol, option, distribution): + adj_vol = ann_vol * expiry**0.5 / 100 + if distribution == "log": + x = (log(forward / strike) + 0.5 * adj_vol**2) / adj_vol + n1, n2 = norm.cdf(x), norm.cdf(x - adj_vol) + payer_price = forward * n1 - strike * n2 + elif distribution == "normal": + x = (forward - strike) / adj_vol + n1 = norm.cdf(x) + payer_price = (forward - strike) * n1 + (adj_vol / (2 * pi) ** 0.5) * exp(-(x**2) / 2) + else: + raise ValueError("`distribution` must be in {'log', 'normal'}.") + + receiver_price = payer_price - (forward - strike) + if option == "payer": + return payer_price + elif option == "receiver": + return receiver_price + elif option == "straddle": + return payer_price + receiver_price + else: + raise ValueError("`option` must be in {'receiver', 'payer', 'straddle'}.") + + +MAXITER = 25 +VOLTOL = 1e-4 + + +def swaption_implied_vol(price, forward, strike, expiry, ini_vol, option, distribution): + v1 = ini_vol + for i in range(MAXITER): + v0 = v1 + c0 = swaption_price(forward, strike, expiry, v0, option, distribution) + c1 = swaption_price(forward, strike, expiry, v0 + 1, option, distribution) + v1 = v0 + (price - c0) / (c1 - c0) + if abs(v1 - v0) < VOLTOL: + return v1 + raise ValueError(f"Failed to converge to tolerance after {MAXITER} iterations.") + + +def swaption_delta(forward, strike, expiry, ann_vol, option, distribution): + d0 = swaption_price(forward - 0.0005, strike, expiry, ann_vol, option, distribution) + d1 = swaption_price(forward + 0.0005, strike, expiry, ann_vol, option, distribution) + return (d1 - d0) * 1000 + + +def swaption_gamma(forward, strike, expiry, ann_vol, option, distribution): + g0 = swaption_delta(forward - 0.0005, strike, expiry, ann_vol, option, distribution) + g1 = swaption_delta(forward + 0.0005, strike, expiry, ann_vol, option, distribution) + return (g1 - g0) * 10 + + +def swaption_vega(forward, strike, expiry, ann_vol, option, distribution): + v0 = swaption_price(forward, strike, expiry, ann_vol - 0.0005, option, distribution) + v1 = swaption_price(forward, strike, expiry, ann_vol + 0.0005, option, distribution) + return (v1 - v0) * 1000 + + +def swaption_theta(forward, strike, expiry, ann_vol, option, distribution): + t0 = swaption_price(forward, strike, expiry, ann_vol, option, distribution) + t1 = swaption_price(forward, strike, expiry - 1 / 252, ann_vol, option, distribution) + return t1 - t0 + + +def swaption_greeks(*args): + return [ + swaption_delta(*args), + swaption_gamma(*args), + swaption_vega(*args), + swaption_theta(*args), + ] diff --git a/rateslib/instruments.py b/rateslib/instruments.py index a98b7162..f5176676 100644 --- a/rateslib/instruments.py +++ b/rateslib/instruments.py @@ -80,7 +80,21 @@ def _get_curve_from_solver(curve, solver): try: # it is a safeguard to load curves from solvers when a solver is # provided and multiple curves might have the same id - return solver.pre_curves[curve.id] + _ = solver.pre_curves[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 KeyError: if defaults.curve_not_in_solver == "ignore": return curve @@ -275,6 +289,7 @@ def delta( solver: Optional[Solver] = None, fx: Optional[Union[FXRates, FXForwards]] = None, base: Optional[str] = None, + local: bool = False, ): """ Calculate delta risk against the calibrating instruments of the @@ -303,6 +318,9 @@ def delta( The base currency to convert cashflows into (3-digit code), set by default. Only used if ``fx_rate`` is an :class:`~rateslib.fx.FXRates` or :class:`~rateslib.fx.FXForwards` object. + local : bool, optional + If `True` will ignore ``base`` - this is equivalent to setting ``base`` to *None*. + Included only for argument signature consistent with *npv*. Returns ------- @@ -314,6 +332,8 @@ def delta( _, fx_, base_ = _get_curves_fx_and_base_maybe_from_solver( None, solver, None, fx, base, None ) + if local: + base_ = None return solver.delta(npv, base_, fx_) def gamma( @@ -322,6 +342,7 @@ def gamma( solver: Optional[Solver] = None, fx: Optional[Union[float, FXRates, FXForwards]] = None, base: Optional[str] = None, + local: bool = False ): """ Calculate cross-gamma risk against the calibrating instruments of the @@ -350,6 +371,9 @@ def gamma( The base currency to convert cashflows into (3-digit code), set by default. Only used if ``fx_rate`` is an :class:`~rateslib.fx.FXRates` or :class:`~rateslib.fx.FXForwards` object. + local : bool, optional + If `True` will ignore ``base``. This is equivalent to setting ``base`` to *None*. + Included only for argument signature consistent with *npv*. Returns ------- @@ -360,6 +384,8 @@ def gamma( _, fx_, base_ = _get_curves_fx_and_base_maybe_from_solver( None, solver, None, fx, base, None ) + if local: + base_ = None # store original order if fx_ is not None: diff --git a/rateslib/periods.py b/rateslib/periods.py index 593083b2..49e87f51 100644 --- a/rateslib/periods.py +++ b/rateslib/periods.py @@ -422,11 +422,11 @@ def npv( raise TypeError( "`curves` have not been supplied correctly. NoneType has been detected." ) - fx, base = _get_fx_and_base(self.currency, fx, base) value = self.cashflow * disc_curve[self.payment] if local: return {self.currency: value} else: + fx, _ = _get_fx_and_base(self.currency, fx, base) return fx * value def cashflows( @@ -868,11 +868,11 @@ def npv( ) if self.payment < disc_curve.node_dates[0]: return 0.0 # payment date is in the past avoid issues with fixings or rates - fx, base = _get_fx_and_base(self.currency, fx, base) value = self.rate(curve) / 100 * self.dcf * disc_curve[self.payment] * -self.notional if local: return {self.currency: value} else: + fx, _ = _get_fx_and_base(self.currency, fx, base) return fx * value def cashflow(self, curve: Union[Curve, LineCurve]) -> Union[None, DualTypes]: @@ -1614,11 +1614,11 @@ def npv( raise TypeError( "`curves` have not been supplied correctly. NoneType has been detected." ) - fx, base = _get_fx_and_base(self.currency, fx, base) value = self.cashflow * disc_curve[self.payment] if local: return {self.currency: value} else: + fx, _ = _get_fx_and_base(self.currency, fx, base) return fx * value def cashflows( @@ -1845,11 +1845,12 @@ def npv( raise TypeError( "`curves` have not been supplied correctly. NoneType has been detected." ) - fx, base = _get_fx_and_base(self.currency, fx, base) + value = self.cashflow(curve) * disc_curve[self.payment] if local: return {self.currency: value} else: + fx, _ = _get_fx_and_base(self.currency, fx, base) return fx * value @property diff --git a/tests/test_instruments.py b/tests/test_instruments.py index 228da8a6..2e0bdbc8 100644 --- a/tests/test_instruments.py +++ b/tests/test_instruments.py @@ -84,120 +84,128 @@ def usdeur(): return Curve(nodes=nodes, interpolation="log_linear") -def test_get_curve_from_solver(): - from rateslib.solver import Solver +class TestCurvesandSolver: - curve = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 1.0}, id="tagged") - inst = [(Value(dt(2023, 1, 1)), ("tagged",), {})] - solver = Solver([curve], inst, [0.975]) + def test_get_curve_from_solver(self): + curve = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 1.0}, id="tagged") + inst = [(Value(dt(2023, 1, 1)), ("tagged",), {})] + solver = Solver([curve], inst, [0.975]) - result = _get_curve_from_solver("tagged", solver) - assert result == curve + result = _get_curve_from_solver("tagged", solver) + assert result == curve - result = _get_curve_from_solver(curve, solver) - assert result == curve + result = _get_curve_from_solver(curve, solver) + assert result == curve - no_curve = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 1.0}, id="not in solver") + no_curve = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 1.0}, id="not in solver") - with default_context("curve_not_in_solver", "ignore"): - result = _get_curve_from_solver(no_curve, solver) - assert result == no_curve - - with pytest.warns(): - with default_context("curve_not_in_solver", "warn"): + with default_context("curve_not_in_solver", "ignore"): result = _get_curve_from_solver(no_curve, solver) assert result == no_curve - with pytest.raises(ValueError, match="`curve` must be in `solver`"): - with default_context("curve_not_in_solver", "raise"): - result = _get_curve_from_solver(no_curve, solver) - - -@pytest.mark.parametrize("solver", [True, False]) -@pytest.mark.parametrize("fxf", [True, False]) -@pytest.mark.parametrize("fx", [None, 2.0]) -@pytest.mark.parametrize("crv", [True, False]) -def test_get_curves_and_fx_from_solver(usdusd, usdeur, eureur, solver, fxf, fx, crv): - curve = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 1.0}, id="tagged") - inst = [(Value(dt(2023, 1, 1)), ("tagged",), {})] - fxfs = FXForwards( - FXRates({"eurusd": 1.05}, settlement=dt(2022, 1, 3)), - {"usdusd": usdusd, "usdeur": usdeur, "eureur": eureur}, - ) - solver = Solver([curve], inst, [0.975], fx=fxfs if fxf else None) if solver else None - curve = curve if crv else None - - if solver and fxf and fx is not None: - with pytest.warns(UserWarning): - # Solver contains an `fx` attribute but an `fx` argument has been supplied + with pytest.warns(): + with default_context("curve_not_in_solver", "warn"): + result = _get_curve_from_solver(no_curve, solver) + assert result == no_curve + + with pytest.raises(ValueError, match="`curve` must be in `solver`"): + with default_context("curve_not_in_solver", "raise"): + result = _get_curve_from_solver(no_curve, solver) + + @pytest.mark.parametrize("solver", [True, False]) + @pytest.mark.parametrize("fxf", [True, False]) + @pytest.mark.parametrize("fx", [None, 2.0]) + @pytest.mark.parametrize("crv", [True, False]) + def test_get_curves_and_fx_from_solver(self, usdusd, usdeur, eureur, solver, fxf, fx, crv): + curve = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 1.0}, id="tagged") + inst = [(Value(dt(2023, 1, 1)), ("tagged",), {})] + fxfs = FXForwards( + FXRates({"eurusd": 1.05}, settlement=dt(2022, 1, 3)), + {"usdusd": usdusd, "usdeur": usdeur, "eureur": eureur}, + ) + solver = Solver([curve], inst, [0.975], fx=fxfs if fxf else None) if solver else None + curve = curve if crv else None + + if solver and fxf and fx is not None: + with pytest.warns(UserWarning): + # Solver contains an `fx` attribute but an `fx` argument has been supplied + crv_result, fx_result, _ = _get_curves_fx_and_base_maybe_from_solver( + None, solver, curve, fx, None, "usd" + ) + else: crv_result, fx_result, _ = _get_curves_fx_and_base_maybe_from_solver( None, solver, curve, fx, None, "usd" ) - else: - crv_result, fx_result, _ = _get_curves_fx_and_base_maybe_from_solver( - None, solver, curve, fx, None, "usd" - ) - - # check the fx results. If fx is specified directly it is returned - # otherwsie it is returned from a solver object if it is available. - if fx is not None: - assert fx_result == 2.0 - elif solver is None: - assert fx_result is None - else: - if fxf: - assert fx_result == fxfs - else: - assert fx_result is None - - assert crv_result == (curve, curve, curve, curve) + # check the fx results. If fx is specified directly it is returned + # otherwsie it is returned from a solver object if it is available. + if fx is not None: + assert fx_result == 2.0 + elif solver is None: + assert fx_result is None + else: + if fxf: + assert fx_result == fxfs + else: + assert fx_result is None -def test_get_curves_and_fx_from_solver_raises(): - from rateslib.solver import Solver + assert crv_result == (curve, curve, curve, curve) - curve = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 1.0}, id="tagged") - inst = [(Value(dt(2023, 1, 1)), ("tagged",), {})] - solver = Solver([curve], inst, [0.975]) + def test_get_curves_and_fx_from_solver_raises(self): + from rateslib.solver import Solver - with pytest.raises(ValueError, match="`curves` must contain Curve, not str, if"): - _get_curves_fx_and_base_maybe_from_solver(None, None, "tagged", None, None, "") + curve = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 1.0}, id="tagged") + inst = [(Value(dt(2023, 1, 1)), ("tagged",), {})] + solver = Solver([curve], inst, [0.975]) - with pytest.raises(ValueError, match="`curves` must contain str curve `id` s"): - _get_curves_fx_and_base_maybe_from_solver(None, solver, "bad_id", None, None, "") + with pytest.raises(ValueError, match="`curves` must contain Curve, not str, if"): + _get_curves_fx_and_base_maybe_from_solver(None, None, "tagged", None, None, "") - with pytest.raises(ValueError, match="Can only supply a maximum of 4 `curves`"): - _get_curves_fx_and_base_maybe_from_solver(None, solver, ["tagged"] * 5, None, None, "") + with pytest.raises(ValueError, match="`curves` must contain str curve `id` s"): + _get_curves_fx_and_base_maybe_from_solver(None, solver, "bad_id", None, None, "") + with pytest.raises(ValueError, match="Can only supply a maximum of 4 `curves`"): + _get_curves_fx_and_base_maybe_from_solver(None, solver, ["tagged"] * 5, None, None, "") -@pytest.mark.parametrize("num", [1, 2, 3, 4]) -def test_get_curves_from_solver_multiply(num): - from rateslib.solver import Solver + @pytest.mark.parametrize("num", [1, 2, 3, 4]) + def test_get_curves_from_solver_multiply(self, num): + from rateslib.solver import Solver - curve = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 1.0}, id="tagged") - inst = [(Value(dt(2023, 1, 1)), ("tagged",), {})] - solver = Solver([curve], inst, [0.975]) - result, _, _ = _get_curves_fx_and_base_maybe_from_solver( - None, solver, ["tagged"] * num, None, None, "" - ) - assert result == (curve, curve, curve, curve) + curve = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 1.0}, id="tagged") + inst = [(Value(dt(2023, 1, 1)), ("tagged",), {})] + solver = Solver([curve], inst, [0.975]) + result, _, _ = _get_curves_fx_and_base_maybe_from_solver( + None, solver, ["tagged"] * num, None, None, "" + ) + assert result == (curve, curve, curve, curve) + def test_get_proxy_curve_from_solver(self, usdusd, usdeur, eureur): + # TODO: check whether curves in fxf but not is solver should be allowed??? + curve = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 1.0}, id="tagged") + inst = [(Value(dt(2023, 1, 1)), ("tagged",), {})] + fxf = FXForwards( + FXRates({"eurusd": 1.05}, settlement=dt(2022, 1, 3)), + {"usdusd": usdusd, "usdeur": usdeur, "eureur": eureur}, + ) + solver = Solver([curve], inst, [0.975], fx=fxf) + curve = fxf.curve("eur", "usd") + irs = IRS(dt(2022, 1, 1), "3m", "Q") -def test_get_proxy_curve_from_solver(usdusd, usdeur, eureur): - # TODO: check whether curves in fxf but not is solver should be allowed??? - curve = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 1.0}, id="tagged") - inst = [(Value(dt(2023, 1, 1)), ("tagged",), {})] - fxf = FXForwards( - FXRates({"eurusd": 1.05}, settlement=dt(2022, 1, 3)), - {"usdusd": usdusd, "usdeur": usdeur, "eureur": eureur}, - ) - solver = Solver([curve], inst, [0.975], fx=fxf) - curve = fxf.curve("eur", "usd") - irs = IRS(dt(2022, 1, 1), "3m", "Q") + # test the curve will return even though it is not included within the solver + # because it is a proxy curve. + irs.npv(curves=curve, solver=solver) - # test the curve will return even though it is not included within the solver - # because it is a proxy curve. - irs.npv(curves=curve, solver=solver) + def test_ambiguous_curve_in_out_id_solver_raises(self): + curve = Curve({dt(2022, 1, 1): 1.0}, id="cloned-id") + curve2 = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 0.99}, id="cloned-id") + solver = Solver( + curves=[curve2], + instruments=[IRS(dt(2022, 1 ,1), "1y", "A", curves="cloned-id")], + s=[5.0], + ) + irs = IRS(dt(2022, 1, 1), "1y", "A", fixed_rate=2.0) + with pytest.raises(ValueError, match="A curve has been supplied, as part of ``curves``,"): + irs.npv(curves=curve, solver=solver) class TestSolverFXandBase: diff --git a/tests/test_solver.py b/tests/test_solver.py index 38d54f5b..a0e12017 100644 --- a/tests/test_solver.py +++ b/tests/test_solver.py @@ -934,7 +934,7 @@ def test_delta_irs_guide(): fixed_rate=6.0, curves="sofr", ) - result = irs.delta(solver=usd_solver) + result = irs.delta(solver=usd_solver, base="eur", local=True) # local overrides base to USD expected = DataFrame( [[0], [16.77263], [32.60487]], index=MultiIndex.from_product( @@ -1250,7 +1250,7 @@ def test_solver_gamma_pnl_explain(): ) assert_frame_equal(delta_base, expected_delta, atol=1e-2, rtol=1e-4) - gamma_base = pf.gamma(solver=solver) + gamma_base = pf.gamma(solver=solver, base="usd", local=True) # local overrrides base to EUR expected_gamma = DataFrame( data=[ [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],