diff --git a/financepy/market/curves/__init__.py b/financepy/market/curves/__init__.py index 196224bf..2a576fb3 100644 --- a/financepy/market/curves/__init__.py +++ b/financepy/market/curves/__init__.py @@ -10,3 +10,4 @@ from .bond_bootstrap_discount_curve import * from .bond_parametric_discount_curve import * from .bond_parametric_yield_curve import * +from .fx_discount_curve import * diff --git a/financepy/market/curves/fx_discount_curve.py b/financepy/market/curves/fx_discount_curve.py new file mode 100644 index 00000000..b5e946b0 --- /dev/null +++ b/financepy/market/curves/fx_discount_curve.py @@ -0,0 +1,87 @@ +# Copyright (C) 2018, 2019, 2020 Dominic O'Kane + +from typing import Union + +import numpy as np + +from ...utils.date import Date +from ...utils.day_count import DayCountTypes +from ...utils.error import FinError +from ...utils.helpers import check_argument_types, label_to_string +from .discount_curve import DiscountCurve +from .interpolator import InterpTypes + + +######################################################################################## +class FXDiscountCurve(DiscountCurve): + """Discount curve implied from FX spot, FX forwards, and a domestic curve.""" + + #################################################################################### + def __init__( + self, + value_dt: Date, + spot_fx_rate: float, + forward_dts: list, + forward_fx_rates: Union[list, np.ndarray], + domestic_curve: DiscountCurve, + interp_type: InterpTypes = InterpTypes.FLAT_FWD_RATES, + time_dc_type: DayCountTypes = DayCountTypes.ACT_365F, + ): + """Create an implied foreign discount curve from FX forward quotes. + + The FX rate convention is domestic currency per unit of foreign currency. + Given spot S, forward F(T), and domestic discount factor DF_dom(T), the + implied foreign discount factor is: + + DF_for(T) = F(T) / S * DF_dom(T) + """ + check_argument_types(self.__init__, locals()) + + if spot_fx_rate <= 0.0: + raise FinError("Spot FX rate must be positive.") + + if len(forward_dts) == 0: + raise FinError("At least one FX forward date is required.") + + if len(forward_dts) != len(forward_fx_rates): + raise FinError("Forward dates and FX forward rates must have same length.") + + if domestic_curve.value_dt != value_dt: + raise FinError("Domestic curve value date must match curve value date.") + + for dt in forward_dts: + if dt <= value_dt: + raise FinError("Forward dates must be after the value date.") + + forward_fx_rates = np.asarray(forward_fx_rates, dtype=float) + + if np.any(~np.isfinite(forward_fx_rates)) or np.any(forward_fx_rates <= 0.0): + raise FinError("FX forward rates must be finite and positive.") + + domestic_dfs = domestic_curve.df(forward_dts) + foreign_dfs = forward_fx_rates / spot_fx_rate * domestic_dfs + + self.spot_fx_rate = spot_fx_rate + self.forward_fx_rates = forward_fx_rates + self.domestic_curve = domestic_curve + + super().__init__( + value_dt, + forward_dts, + foreign_dfs, + interp_type, + time_dc_type, + ) + + #################################################################################### + def __repr__(self): + s = label_to_string("OBJECT TYPE", type(self).__name__) + s += label_to_string("SPOT FX RATE", self.spot_fx_rate) + s += "\n" + s += super().__repr__() + return s + + #################################################################################### + def _print(self): + """Simple print function for backward compatibility.""" + print(self) diff --git a/regression_tests/TestFinFXDiscountCurve.py b/regression_tests/TestFinFXDiscountCurve.py new file mode 100644 index 00000000..8e4aaff0 --- /dev/null +++ b/regression_tests/TestFinFXDiscountCurve.py @@ -0,0 +1,56 @@ +# Copyright (C) 2018, 2019, 2020 Dominic O'Kane + +import add_fp_to_path + +from financepy.utils.date import Date +from financepy.utils.frequency import FrequencyTypes +from financepy.market.curves.discount_curve_flat import DiscountCurveFlat +from financepy.market.curves.fx_discount_curve import FXDiscountCurve +from FinTestCases import FinTestCases, global_test_case_mode + +test_cases = FinTestCases(__file__, global_test_case_mode) + + +######################################################################################## +def test_fin_fx_discount_curve(): + value_dt = Date(1, 1, 2024) + + spot_fx_rate = 150.0 + forward_tenors = ["1M", "3M", "6M"] + forward_dts = value_dt.add_months([1, 3, 6]) + forward_fx_rates = [149.8, 149.2, 148.5] + + domestic_curve = DiscountCurveFlat( + value_dt, + 0.05, + FrequencyTypes.CONTINUOUS, + ) + + foreign_curve = FXDiscountCurve( + value_dt, + spot_fx_rate, + forward_dts, + forward_fx_rates, + domestic_curve, + ) + + test_cases.header("TENOR", "INPUT_FWD", "IMPLIED_FWD") + + for tenor, forward_dt, forward_fx_rate in zip( + forward_tenors, + forward_dts, + forward_fx_rates, + ): + implied_forward = ( + spot_fx_rate + * foreign_curve.df(forward_dt) + / domestic_curve.df(forward_dt) + ) + + test_cases.print(tenor, forward_fx_rate, implied_forward) + + +######################################################################################## + +test_fin_fx_discount_curve() +test_cases.compare_test_cases() diff --git a/regression_tests/golden/TestFinFXDiscountCurve_GOLDEN.testLog b/regression_tests/golden/TestFinFXDiscountCurve_GOLDEN.testLog new file mode 100644 index 00000000..d1e6879f --- /dev/null +++ b/regression_tests/golden/TestFinFXDiscountCurve_GOLDEN.testLog @@ -0,0 +1,5 @@ +File Created on:20260711_000000 +HEADER,TENOR,INPUT_FWD,IMPLIED_FWD, +RESULTS,1M,149.80000000,149.80000000, +RESULTS,3M,149.20000000,149.20000000, +RESULTS,6M,148.50000000,148.50000000,