diff --git a/financepy/products/rates/ibor_future.py b/financepy/products/rates/ibor_future.py index bb7d27e0..b341e8e4 100644 --- a/financepy/products/rates/ibor_future.py +++ b/financepy/products/rates/ibor_future.py @@ -2,14 +2,10 @@ # Copyright (C) 2018, 2019, 2020 Dominic O'Kane ############################################################################## -# TODO: Add functionality around settlement -# TODO: Write test function -# TODO: Handle 1 month futures contracts - import numpy as np from ...utils.error import FinError -from ...utils.day_count import DayCountTypes +from ...utils.day_count import DayCount, DayCountTypes from ...utils.global_vars import G_DAYS_IN_YEAR from ...utils.math import ONE_MILLION from ...utils.date import Date @@ -30,7 +26,7 @@ def __init__( self, today_dt: Date, future_number: int, # The number of the future after today_dt - future_tenor: str = "3M", # '1M', '2M', '3M' + future_tenor: str = "3M", # '1M', '3M' accrual_dc_type: DayCountTypes = DayCountTypes.ACT_360, contract_size: float = ONE_MILLION, ): @@ -44,22 +40,68 @@ def __init__( if future_number < 1: raise FinError("Future number must be 1 or more") - if future_tenor not in ["3M", "3m"]: - raise FinError("Only 3M IMM futures handled currently.") + future_tenor = future_tenor.upper() - self.delivery_dt = today_dt.next_imm_date() + if future_tenor not in ["1M", "3M"]: + raise FinError("Only 1M and 3M IMM futures handled currently.") - for _ in range(0, future_number - 1): - self.delivery_dt = self.delivery_dt.next_imm_date() + self.future_tenor = future_tenor + self.delivery_dt = self._first_delivery_dt(today_dt, future_tenor) - self.end_of_interest_period = self.delivery_dt.next_imm_date() + for _ in range(0, future_number - 1): + self.delivery_dt = self._next_delivery_dt( + self.delivery_dt, future_tenor + ) + self.end_of_interest_period = self._next_delivery_dt( + self.delivery_dt, future_tenor + ) self.last_trading_dt = self.delivery_dt.add_days(-2) self.accrual_dc_type = accrual_dc_type self.contract_size = contract_size ########################################################################### + @staticmethod + def _next_monthly_imm_date(dt: Date): + """Return the next third Wednesday after dt, for any month.""" + + m_imm = dt.m + y_imm = dt.y + d_imm = dt.third_wednesday_of_month(m_imm, y_imm) + + if dt.d >= d_imm: + next_month_dt = dt.add_months(1) + m_imm = next_month_dt.m + y_imm = next_month_dt.y + d_imm = next_month_dt.third_wednesday_of_month(m_imm, y_imm) + + return Date(d_imm, m_imm, y_imm) + + ########################################################################### + + @staticmethod + def _first_delivery_dt(today_dt: Date, future_tenor: str): + """Return the first delivery date after today for the tenor.""" + + if future_tenor == "1M": + return IborFuture._next_monthly_imm_date(today_dt) + + return today_dt.next_imm_date() + + ########################################################################### + + @staticmethod + def _next_delivery_dt(delivery_dt: Date, future_tenor: str): + """Return the next delivery date after an existing delivery date.""" + + if future_tenor == "1M": + return IborFuture._next_monthly_imm_date(delivery_dt) + + return delivery_dt.next_imm_date() + + ########################################################################### + def to_fra(self, futures_price, convexity): """Convert the futures contract to a IborFRA object so it can be used to boostrap a Ibor curve. For this we need to adjust the futures @@ -87,6 +129,47 @@ def futures_rate(self, futures_price): ########################################################################### + def accrual_factor(self): + """Return the accrual factor for the futures interest period.""" + + dc = DayCount(self.accrual_dc_type) + return dc.year_frac( + self.delivery_dt, self.end_of_interest_period + )[0] + + ########################################################################### + + def basis_point_value(self): + """Return the cash value of one basis point move in futures price.""" + + return self.contract_size * self.accrual_factor() / 10000.0 + + ########################################################################### + + def settlement_amount( + self, + futures_price: float, + settlement_price: float, + num_contracts: float = 1.0, + ): + """Return the cash settlement amount for a futures price move. + + The amount is for a long position. Use a negative number of contracts + for a short position. Prices are quoted in futures price points, for + example 97.50. + """ + + price_change = settlement_price - futures_price + return ( + num_contracts + * self.contract_size + * self.accrual_factor() + * price_change + / 100.0 + ) + + ########################################################################### + def fra_rate(self, futures_price, convexity): """Convert futures price and convexity to a FRA rate using the BBG negative convexity (in percent). This is then divided by 100 before diff --git a/unit_tests/test_FinIborFuture.py b/unit_tests/test_FinIborFuture.py index 4b8c55ee..c5f13f38 100644 --- a/unit_tests/test_FinIborFuture.py +++ b/unit_tests/test_FinIborFuture.py @@ -1,7 +1,10 @@ # Copyright (C) 2018, 2019, 2020 Dominic O'Kane +import pytest + from financepy.utils.date_format import set_date_format, DateFormatTypes from financepy.utils.date import Date +from financepy.utils.error import FinError from financepy.products.rates.ibor_future import IborFuture set_date_format(DateFormatTypes.UK_LONG) @@ -13,26 +16,72 @@ def test_fin_ibor_future(): today_date = Date(5, 5, 2020) - i = 1 - fut = IborFuture(today_date, i, "3M") - fra = fut.to_fra(0.020, 0.0) - assert fut.delivery_dt == Date(17, 6, 2020) - assert fra.start_dt == Date(17, 6, 2020) - - i = 4 - fut = IborFuture(today_date, i, "3M") - fra = fut.to_fra(0.020, 0.0) - assert fut.delivery_dt == Date(17, 3, 2021) - assert fra.start_dt == Date(17, 3, 2021) - - i = 7 - fut = IborFuture(today_date, i, "3M") - fra = fut.to_fra(0.020, 0.0) - assert fut.delivery_dt == Date(15, 12, 2021) - assert fra.start_dt == Date(15, 12, 2021) - - i = 10 - fut = IborFuture(today_date, i, "3M") - fra = fut.to_fra(0.020, 0.0) - assert fut.delivery_dt == Date(21, 9, 2022) - assert fra.start_dt == Date(21, 9, 2022) + expected_dates = [ + (1, Date(17, 6, 2020), Date(16, 9, 2020)), + (4, Date(17, 3, 2021), Date(16, 6, 2021)), + (7, Date(15, 12, 2021), Date(16, 3, 2022)), + (10, Date(21, 9, 2022), Date(21, 12, 2022)), + ] + + for future_number, delivery_dt, end_dt in expected_dates: + fut = IborFuture(today_date, future_number, "3M") + fra = fut.to_fra(97.50, 0.0) + + assert fut.future_tenor == "3M" + assert fut.delivery_dt == delivery_dt + assert fut.end_of_interest_period == end_dt + assert fra.start_dt == delivery_dt + assert fra.maturity_dt == end_dt + assert fra.fra_rate == pytest.approx(0.0250) + + +def test_fin_ibor_future_one_month_contracts(): + + today_date = Date(5, 5, 2020) + + expected_dates = [ + (1, Date(20, 5, 2020), Date(17, 6, 2020)), + (2, Date(17, 6, 2020), Date(15, 7, 2020)), + (3, Date(15, 7, 2020), Date(19, 8, 2020)), + (4, Date(19, 8, 2020), Date(16, 9, 2020)), + ] + + for future_number, delivery_dt, end_dt in expected_dates: + fut = IborFuture(today_date, future_number, "1m") + fra = fut.to_fra(97.50, 0.0) + + assert fut.future_tenor == "1M" + assert fut.delivery_dt == delivery_dt + assert fut.end_of_interest_period == end_dt + assert fra.start_dt == delivery_dt + assert fra.maturity_dt == end_dt + + +def test_fin_ibor_future_settlement_amount(): + + today_date = Date(5, 5, 2020) + fut = IborFuture(today_date, 1, "3M") + + assert fut.last_trading_dt == Date(15, 6, 2020) + assert fut.accrual_factor() == pytest.approx(91.0 / 360.0) + assert fut.basis_point_value() == pytest.approx(25.2777777778) + assert fut.settlement_amount(97.50, 97.51) == pytest.approx( + fut.basis_point_value() + ) + assert fut.settlement_amount(97.51, 97.50) == pytest.approx( + -fut.basis_point_value() + ) + assert fut.settlement_amount(97.50, 97.51, 3.0) == pytest.approx( + 3.0 * fut.basis_point_value() + ) + + +def test_fin_ibor_future_invalid_inputs(): + + today_date = Date(5, 5, 2020) + + with pytest.raises(FinError): + IborFuture(today_date, 0, "3M") + + with pytest.raises(FinError): + IborFuture(today_date, 1, "2M")