Skip to content

Commit

Permalink
REG: base argument to FXForwards fails to order currencies (#669)
Browse files Browse the repository at this point in the history
Co-authored-by: JHM Darbyshire (win11) <[email protected]>
  • Loading branch information
attack68 and attack68 authored Jan 31, 2025
1 parent 0dfa5ae commit 58427b0
Show file tree
Hide file tree
Showing 4 changed files with 115 additions and 4 deletions.
5 changes: 5 additions & 0 deletions docs/source/i_whatsnew.rst
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ objects now have specific methods to allow *updates*.
- :class:`~rateslib.instruments.STIRFuture` now correctly handles *NPV* when ``fx``
is provided as an, potentially unused, argument.
(`653 <https://github.com/attack68/rateslib/pull/653>`_)
* - Bug
- :class:`~rateslib.fx.FXForwards` corrects a bug which possibly mis-ordered some
currencies if a ``base`` argument was given at initialisation, yielding mis-stated FX rates
for some pair combinations.
(`669 <https://github.com/attack68/rateslib/pull/669>`_)
* - Bug
- :meth:`~rateslib.periods.FloatPeriod.rate` now correctly calculates when ``fixings``
are provided in any of the acceptable formats and contains all data to do so, in the
Expand Down
7 changes: 4 additions & 3 deletions python/rateslib/fx/fx_forwards.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ def __init__(
self._validate_fx_curves(fx_curves)
self.fx_rates: FXRates | list[FXRates] = fx_rates
self._calculate_immediate_rates(base, init=True)
assert self.currencies_list == self.fx_rates_immediate.currencies_list # noqa: S101
self._set_new_state()

def _get_composited_state(self) -> int:
Expand Down Expand Up @@ -216,7 +217,7 @@ def _calculate_immediate_rates(self, base: str | NoInput, init: bool) -> None:
if init:
self.currencies = self.fx_rates.currencies
self.q = len(self.currencies.keys())
self.currencies_list: list[str] = list(self.currencies.keys())
self.currencies_list: list[str] = self.fx_rates.currencies_list
self.transform = self._get_forwards_transformation_matrix(
self.q,
self.currencies,
Expand All @@ -241,7 +242,7 @@ def _calculate_immediate_rates(self, base: str | NoInput, init: bool) -> None:
pair: self.fx_rates[0].settlement for pair in self.fx_rates[0].pairs
}

# Now itertate through the remaining FXRates objects and patch them into the fxf
# Now iterate through the remaining FXRates objects and patch them into the fxf
for fx_rates_obj in self.fx_rates[1:]:
# create sub FXForwards for each FXRates instance and re-combine.
# This reuses the arg validation of a single FXRates object and
Expand Down Expand Up @@ -327,7 +328,7 @@ def _calculate_immediate_rates_same_settlement_frame(self) -> FXRates:
pair = f"{cash_ccy}{coll_ccy}"
fx_rates_immediate.update({pair: self.fx_rates.fx_array[row, col] * v_i / w_i})

fx_rates_immediate_ = FXRates(fx_rates_immediate, self.immediate, self.base)
fx_rates_immediate_ = FXRates(fx_rates_immediate, self.immediate, self.currencies_list[0])
return fx_rates_immediate_.restate(self.fx_rates.pairs, keep_ad=True)

def __repr__(self) -> str:
Expand Down
2 changes: 1 addition & 1 deletion python/rateslib/fx/fx_rates.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ def restate(self, pairs: list[str], keep_ad: bool = False) -> FXRates:
fxr2 = fxr.restate({"eurusd", "gbpusd"})
fxr2.rates_table()
"""
if set(pairs) == set(self.pairs) and keep_ad:
if pairs == self.pairs and keep_ad:
return self.__copy__() # no restate needed but return new instance

restated_fx_rates = FXRates(
Expand Down
105 changes: 105 additions & 0 deletions python/tests/test_fx.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import datetime as dt
from random import choice, shuffle

import numpy as np
import pytest
Expand Down Expand Up @@ -278,6 +279,18 @@ def usdeur():
return Curve(nodes=nodes, interpolation="log_linear")


@pytest.fixture
def cadcad():
nodes = {dt(2022, 1, 1): 1.00, dt(2022, 4, 1): 0.987}
return Curve(nodes=nodes, interpolation="log_linear")


@pytest.fixture
def cadcol():
nodes = {dt(2022, 1, 1): 1.00, dt(2022, 4, 1): 0.984}
return Curve(nodes=nodes, interpolation="log_linear")


def test_fxforwards_repr(usdusd, eureur, usdeur) -> None:
fxf = FXForwards(
FXRates({"usdeur": 2.0}, settlement=dt(2022, 1, 3)),
Expand Down Expand Up @@ -679,6 +692,98 @@ def test_forwards_codependent_curve_raise(usdusd) -> None:
)


class TestFXForwardsBase:
# these tests will validate the base argument supplied to the FXForwards object
# in different framework type constructions

def test_single_system(self, usdusd, eureur):
# test that creating 2 currencies setting base as either yields the same FX rates.
fxr = FXRates({"eurusd": 200.0}, settlement=dt(2022, 1, 3))
fxf1 = FXForwards(fxr, {"eureur": eureur, "eurusd": eureur, "usdusd": usdusd}, base="usd")
fxf2 = FXForwards(fxr, {"eureur": eureur, "eurusd": eureur, "usdusd": usdusd}, base="eur")
res1 = fxf1.rate("eurusd", dt(2022, 3, 1))
res2 = fxf2.rate("eurusd", dt(2022, 3, 1))
assert res1 == res2

@pytest.mark.parametrize("base", ["usd", "eur", "cad", NoInput(0)])
@pytest.mark.parametrize("idx", [0, 1])
def test_multi_currency_system(self, base, idx, usdusd, eureur, cadcad, cadcol, usdeur):
ccys = ["usd", "eur", "cad"]
shuffle(ccys)
pairs = [f"{ccys[0]}{ccys[1]}", f"{ccys[idx]}{ccys[2]}"]
fxr = FXRates(dict(zip(pairs, [5.0, 15.0])), base=base, settlement=dt(2022, 1, 3))

shuffle(ccys)
curv_pairs = [f"{ccys[0]}{ccys[1]}", f"{ccys[idx]}{ccys[2]}"]
fxc = {
"eureur": eureur,
"cadcad": cadcad,
"usdusd": usdusd,
**dict(zip(curv_pairs, [cadcol, usdeur])),
}
fxf1 = FXForwards(fxr, fxc, base="usd")
fxf2 = FXForwards(fxr, fxc, base="eur")
fxf3 = FXForwards(fxr, fxc, base="cad")
fxf4 = FXForwards(fxr, fxc, base=NoInput(0))

shuffle(ccys)
r1 = fxf1.rate(f"{ccys[0]}{ccys[1]}", dt(2022, 2, 27))
r2 = fxf2.rate(f"{ccys[0]}{ccys[1]}", dt(2022, 2, 27))
r3 = fxf3.rate(f"{ccys[0]}{ccys[1]}", dt(2022, 2, 27))
r4 = fxf4.rate(f"{ccys[0]}{ccys[1]}", dt(2022, 2, 27))

assert r1 == r2
assert r1 == r3
assert r1 == r4

@pytest.mark.parametrize("base1", [NoInput(0), "usd", "cad"])
@pytest.mark.parametrize("base2", [NoInput(0), "eur", "usd"])
@pytest.mark.parametrize("pair1", ["cadusd", "usdcad"])
@pytest.mark.parametrize("pair2", ["usdeur", "eurusd"])
def test_separable_system(
self, usdusd, eureur, usdeur, cadcad, cadcol, base1, base2, pair1, pair2
):
fxr1 = FXRates({pair1: 1.25}, settlement=dt(2022, 1, 3), base=base1)
fxr2 = FXRates({pair2: 2.0}, settlement=dt(2022, 1, 2), base=base2)

curves = {
"usdusd": usdusd,
"eureur": eureur,
"cadcad": cadcad,
"cadusd": cadcol,
"usdeur": usdeur,
}
fxf1 = FXForwards([fxr2, fxr1], curves, base="usd")
fxf2 = FXForwards([fxr2, fxr1], curves, base="eur")
fxf3 = FXForwards([fxr2, fxr1], curves, base="cad")

for pair in ["usdcad", "cadeur", "eurusd"]:
assert fxf1.rate(pair, dt(2022, 3, 20)) == fxf2.rate(pair, dt(2022, 3, 20))
assert fxf1.rate(pair, dt(2022, 3, 20)) == fxf3.rate(pair, dt(2022, 3, 20))

def test_dependent_acyclic_system(self, usdusd, eureur, usdeur, cadcad, cadcol):
pair = choice(["usdcad", "cadusd"])
pair2 = choice(["eurusd", "usdeur"])

fxr1 = FXRates({pair2: 1.25}, settlement=dt(2022, 1, 3))
fxr2 = FXRates({pair: 2.0}, settlement=dt(2022, 1, 2))

curves = {
"usdusd": usdusd,
"eureur": eureur,
"cadcad": cadcad,
"cadeur": cadcol,
"usdeur": usdeur,
}
fxf1 = FXForwards([fxr1, fxr2], curves, base="usd")
fxf2 = FXForwards([fxr1, fxr2], curves, base="eur")
fxf3 = FXForwards([fxr1, fxr2], curves, base="cad")

for pair in ["usdcad", "cadeur", "eurusd"]:
assert fxf1.rate(pair, dt(2022, 3, 20)) == fxf2.rate(pair, dt(2022, 3, 20))
assert fxf1.rate(pair, dt(2022, 3, 20)) == fxf3.rate(pair, dt(2022, 3, 20))


def test_multiple_settlement_forwards() -> None:
fxr1 = FXRates({"usdeur": 0.95}, dt(2022, 1, 3))
fxr2 = FXRates({"usdcad": 1.1}, dt(2022, 1, 2))
Expand Down

0 comments on commit 58427b0

Please sign in to comment.