diff --git a/lifelib_pyql/portfolio/api.py b/lifelib_pyql/portfolio/api.py index 3dc8b4f..54512ea 100644 --- a/lifelib_pyql/portfolio/api.py +++ b/lifelib_pyql/portfolio/api.py @@ -1 +1,2 @@ from .time.schedules import Schedules +from .instruments.bonds import FixedRateBonds diff --git a/lifelib_pyql/portfolio/instruments/__init__.py b/lifelib_pyql/portfolio/instruments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lifelib_pyql/portfolio/instruments/bonds.pxd b/lifelib_pyql/portfolio/instruments/bonds.pxd new file mode 100644 index 0000000..ca2bd2a --- /dev/null +++ b/lifelib_pyql/portfolio/instruments/bonds.pxd @@ -0,0 +1,11 @@ +cimport numpy as cnp + +cdef class FixedRateBonds: + cdef cnp.ndarray _settlement_days # 1D int64, (N,) + cdef cnp.ndarray _face_amounts # 1D float64, (N,) + cdef object _schedules # Schedules + cdef cnp.ndarray _coupons # 1D or 2D float64 + cdef object _accrual_day_counter # DayCounter + cdef int _payment_convention + cdef cnp.ndarray _redemptions # 1D float64, (N,) + cdef cnp.ndarray _issue_dates # 1D datetime64[D], (N,) or None diff --git a/lifelib_pyql/portfolio/instruments/bonds.pyx b/lifelib_pyql/portfolio/instruments/bonds.pyx new file mode 100644 index 0000000..6fe1873 --- /dev/null +++ b/lifelib_pyql/portfolio/instruments/bonds.pyx @@ -0,0 +1,212 @@ +# cython: language_level=3str, embedsignature=True, auto_pickle=False +"""Vectorized FixedRateBond collection for bulk bond construction.""" + +import numpy as np +cimport numpy as cnp +cnp.import_array() + +from lifelib_pyql.types cimport Natural, Real +cimport lifelib_pyql.time._date as _date +from lifelib_pyql.time._date cimport serial_type +from lifelib_pyql.time.businessdayconvention cimport ( + BusinessDayConvention, Following) +from lifelib_pyql.time.date cimport Date +from lifelib_pyql.time.daycounter cimport DayCounter +from lifelib_pyql.time.schedule cimport Schedule +from lifelib_pyql.instruments.bonds.fixedratebond cimport FixedRateBond +from lifelib_pyql.portfolio.time.schedules cimport Schedules + + +cdef cnp.int64_t NAT_INT64 = np.datetime64('NaT', 'D').view('i8') + + +cdef Date _date_from_np_dt64(cnp.int64_t val): + """Convert a numpy datetime64[D] int64 value to a pyql Date.""" + if val == NAT_INT64: + return Date() + cdef Date d = Date.__new__(Date) + d._thisptr = _date.Date((val + 25569)) + return d + + +cdef class FixedRateBonds: + """A collection of fixed-rate bonds stored as parallel arrays. + + Stores parameters for bulk FixedRateBond construction. + Individual bonds are reconstructed on demand via indexing. + + Parameters + ---------- + settlement_days : ndarray[int] + Settlement days per bond, shape (N,). + face_amounts : ndarray[float] + Face amounts per bond, shape (N,). + schedules : Schedules + Payment schedules for all bonds. + coupons : ndarray[float] + Coupon rates. Shape (N,) for a single coupon per bond, + or (N, M) for up to M coupons per bond (NaN-padded). + accrual_day_counter : DayCounter + Day counter for accrual (shared across all bonds). + payment_convention : BusinessDayConvention, optional + Payment convention (shared). Default: Following. + redemptions : float or ndarray[float], optional + Redemption amounts, scalar or shape (N,). Default: 100.0. + issue_dates : ndarray[datetime64[D]] or None, optional + Issue dates per bond, shape (N,). Default: None. + """ + + def __init__(self, settlement_days, face_amounts, + Schedules schedules, coupons, + DayCounter accrual_day_counter, + BusinessDayConvention payment_convention=Following, + redemptions=100.0, + issue_dates=None): + + cdef Py_ssize_t n = len(schedules) + + cdef cnp.ndarray sd_arr = np.asarray(settlement_days, dtype=np.int64) + cdef cnp.ndarray fa_arr = np.asarray(face_amounts, dtype=np.float64) + cdef cnp.ndarray cpn_arr = np.asarray(coupons, dtype=np.float64) + + if sd_arr.shape[0] != n: + raise ValueError( + f"settlement_days length {sd_arr.shape[0]} != " + f"schedules length {n}") + if fa_arr.shape[0] != n: + raise ValueError( + f"face_amounts length {fa_arr.shape[0]} != " + f"schedules length {n}") + if cpn_arr.ndim == 1: + if cpn_arr.shape[0] != n: + raise ValueError( + f"coupons length {cpn_arr.shape[0]} != " + f"schedules length {n}") + elif cpn_arr.ndim == 2: + if cpn_arr.shape[0] != n: + raise ValueError( + f"coupons rows {cpn_arr.shape[0]} != " + f"schedules length {n}") + else: + raise ValueError("coupons must be 1D or 2D array") + + # Handle redemptions: scalar broadcast or array + cdef cnp.ndarray red_arr = np.broadcast_to( + np.asarray(redemptions, dtype=np.float64), (n,)).copy() + + # Handle issue_dates + cdef cnp.ndarray id_arr + if issue_dates is not None: + id_arr = np.asarray(issue_dates, dtype='datetime64[D]') + if id_arr.shape[0] != n: + raise ValueError( + f"issue_dates length {id_arr.shape[0]} != " + f"schedules length {n}") + else: + id_arr = None + + self._settlement_days = sd_arr + self._face_amounts = fa_arr + self._schedules = schedules + self._coupons = cpn_arr + self._accrual_day_counter = accrual_day_counter + self._payment_convention = payment_convention + self._redemptions = red_arr + self._issue_dates = id_arr + + def __len__(self): + return len(self._schedules) + + @property + def settlement_days(self): + """1D int array of settlement days per bond.""" + return self._settlement_days + + @property + def face_amounts(self): + """1D float array of face amounts per bond.""" + return self._face_amounts + + @property + def schedules(self): + """Schedules object for all bonds.""" + return self._schedules + + @property + def coupons(self): + """Coupon rates: 1D (N,) or 2D (N, M) float array.""" + return self._coupons + + @property + def redemptions(self): + """1D float array of redemption amounts per bond.""" + return self._redemptions + + @property + def issue_dates(self): + """1D datetime64[D] array of issue dates, or None.""" + return self._issue_dates + + def __getitem__(self, index): + """Index or slice the collection. + + Parameters + ---------- + index : int or slice + int returns a singular FixedRateBond, slice returns a new + FixedRateBonds. + """ + cdef Date issue_dt + cdef FixedRateBonds result + + if isinstance(index, int): + if index < 0: + index += len(self) + if index < 0 or index >= len(self): + raise IndexError( + f"index {index} out of range for FixedRateBonds of " + f"length {len(self)}") + + # Reconstruct singular FixedRateBond from stored params + schedule = self._schedules[index] # Returns a Schedule + + if self._coupons.ndim == 1: + cpn_list = [float(self._coupons[index])] + else: + row = self._coupons[index] + cpn_list = [float(v) for v in row if not np.isnan(v)] + + if self._issue_dates is not None: + issue_dt = _date_from_np_dt64( + self._issue_dates.view('i8')[index]) + else: + issue_dt = Date() + + return FixedRateBond( + self._settlement_days[index], + self._face_amounts[index], + schedule, + cpn_list, + self._accrual_day_counter, + self._payment_convention, + self._redemptions[index], + issue_dt, + ) + elif isinstance(index, slice): + result = FixedRateBonds.__new__(FixedRateBonds) + result._settlement_days = self._settlement_days[index] + result._face_amounts = self._face_amounts[index] + result._schedules = self._schedules[index] + result._coupons = self._coupons[index] + result._accrual_day_counter = self._accrual_day_counter + result._payment_convention = self._payment_convention + result._redemptions = self._redemptions[index] + result._issue_dates = (self._issue_dates[index] + if self._issue_dates is not None + else None) + return result + else: + raise TypeError("index must be int or slice") + + def __repr__(self): + return f"" diff --git a/test/test_portfolio_bonds.py b/test/test_portfolio_bonds.py new file mode 100644 index 0000000..f5d94d3 --- /dev/null +++ b/test/test_portfolio_bonds.py @@ -0,0 +1,218 @@ +import unittest +import numpy as np + +from lifelib_pyql.time.date import Date, Period, Jul, Months +from lifelib_pyql.time.businessdayconvention import ( + ModifiedFollowing, Following) +from lifelib_pyql.time.calendars.target import TARGET +from lifelib_pyql.time.schedule import Schedule +from lifelib_pyql.time.dategeneration import DateGeneration +from lifelib_pyql.time.frequency import Annual +from lifelib_pyql.time.daycounters.actual_actual import ActualActual +from lifelib_pyql.instruments.bonds import FixedRateBond + + +class FixedRateBondsTestCase(unittest.TestCase): + """Tests for the vectorized FixedRateBonds class.""" + + def setUp(self): + from lifelib_pyql.portfolio.time.schedules import Schedules + from lifelib_pyql.portfolio.instruments.bonds import FixedRateBonds + self.FixedRateBonds = FixedRateBonds + self.calendar = TARGET() + + # Three 10-year annual bonds with different start dates and coupons + self.effective_dates = np.array( + ['2006-07-10', '2007-01-15', '2008-03-01'], dtype='datetime64[D]') + self.termination_dates = np.array( + ['2016-07-10', '2017-01-15', '2018-03-01'], dtype='datetime64[D]') + self.tenors = np.array([12, 12, 12]) # annual + + self.schedules = Schedules( + self.effective_dates, self.termination_dates, self.tenors, + max_size=15, calendar=self.calendar, + convention=ModifiedFollowing, + termination_date_convention=ModifiedFollowing, + rule=DateGeneration.Backward, end_of_month=False) + + self.settlement_days = np.array([3, 3, 3]) + self.face_amounts = np.array([100.0, 100.0, 100.0]) + self.coupons = np.array([0.05, 0.04, 0.06]) + self.day_counter = ActualActual(ActualActual.ISMA) + self.issue_dates = self.effective_dates.copy() + + def _make_bonds(self, **overrides): + """Helper to construct FixedRateBonds with optional overrides.""" + kwargs = dict( + settlement_days=self.settlement_days, + face_amounts=self.face_amounts, + schedules=self.schedules, + coupons=self.coupons, + accrual_day_counter=self.day_counter, + payment_convention=Following, + redemptions=100.0, + issue_dates=self.issue_dates, + ) + kwargs.update(overrides) + return self.FixedRateBonds(**kwargs) + + def test_construction_and_len(self): + bonds = self._make_bonds() + self.assertEqual(len(bonds), 3) + + def test_repr(self): + bonds = self._make_bonds() + self.assertEqual(repr(bonds), "") + + def test_properties(self): + bonds = self._make_bonds() + np.testing.assert_array_equal(bonds.settlement_days, self.settlement_days) + np.testing.assert_array_equal(bonds.face_amounts, self.face_amounts) + np.testing.assert_array_equal(bonds.coupons, self.coupons) + np.testing.assert_array_equal(bonds.redemptions, np.array([100.0, 100.0, 100.0])) + np.testing.assert_array_equal(bonds.issue_dates, self.issue_dates) + self.assertIs(bonds.schedules, self.schedules) + + def test_indexing_int(self): + bonds = self._make_bonds() + bond = bonds[0] + self.assertIsInstance(bond, FixedRateBond) + + def test_indexing_negative(self): + bonds = self._make_bonds() + bond = bonds[-1] + self.assertIsInstance(bond, FixedRateBond) + + def test_indexing_slice(self): + bonds = self._make_bonds() + sub = bonds[0:2] + self.assertIsInstance(sub, self.FixedRateBonds) + self.assertEqual(len(sub), 2) + + def test_indexing_out_of_range(self): + bonds = self._make_bonds() + with self.assertRaises(IndexError): + bonds[10] + + def test_indexing_invalid_type(self): + bonds = self._make_bonds() + with self.assertRaises(TypeError): + bonds["foo"] + + def test_consistency_with_singular(self): + """Verify that indexed bonds match individually constructed FixedRateBond.""" + bonds = self._make_bonds() + + for i in range(3): + bond = bonds[i] + schedule = self.schedules[i] + + singular = FixedRateBond( + int(self.settlement_days[i]), + float(self.face_amounts[i]), + schedule, + [float(self.coupons[i])], + self.day_counter, + Following, + 100.0, + Date( + self.issue_dates[i].astype('datetime64[D]').item().day, + self.issue_dates[i].astype('datetime64[D]').item().month, + self.issue_dates[i].astype('datetime64[D]').item().year, + ), + ) + + self.assertEqual(bond.settlement_days, singular.settlement_days, + f"Bond {i}: settlement_days mismatch") + self.assertEqual(bond.start_date, singular.start_date, + f"Bond {i}: start_date mismatch") + self.assertEqual(bond.maturity_date, singular.maturity_date, + f"Bond {i}: maturity_date mismatch") + self.assertEqual(bond.issue_date, singular.issue_date, + f"Bond {i}: issue_date mismatch") + + def test_slice_then_index(self): + """Slicing then indexing should produce a valid bond.""" + bonds = self._make_bonds() + sub = bonds[1:3] + self.assertEqual(len(sub), 2) + bond = sub[0] + self.assertIsInstance(bond, FixedRateBond) + # The bond from sub[0] should match bonds[1] + original = bonds[1] + self.assertEqual(bond.start_date, original.start_date) + self.assertEqual(bond.maturity_date, original.maturity_date) + + def test_scalar_redemptions(self): + """Scalar redemption should broadcast to all bonds.""" + bonds = self._make_bonds(redemptions=105.0) + np.testing.assert_array_equal( + bonds.redemptions, np.array([105.0, 105.0, 105.0])) + + def test_array_redemptions(self): + """Array redemptions should be stored per bond.""" + reds = np.array([100.0, 105.0, 110.0]) + bonds = self._make_bonds(redemptions=reds) + np.testing.assert_array_equal(bonds.redemptions, reds) + + def test_no_issue_dates(self): + """issue_dates=None should be supported.""" + bonds = self._make_bonds(issue_dates=None) + self.assertIsNone(bonds.issue_dates) + # Indexing should still work, returning a bond with null issue date + bond = bonds[0] + self.assertIsInstance(bond, FixedRateBond) + + def test_2d_coupons(self): + """2D coupon array should work (multiple coupons per bond, NaN-padded).""" + cpn_2d = np.array([ + [0.05, 0.04], + [0.03, np.nan], + [0.06, 0.05], + ]) + bonds = self._make_bonds(coupons=cpn_2d) + self.assertEqual(bonds.coupons.ndim, 2) + self.assertEqual(bonds.coupons.shape, (3, 2)) + # Bond 1 should have only one coupon (NaN filtered out) + bond = bonds[1] + self.assertIsInstance(bond, FixedRateBond) + + def test_mismatched_settlement_days_length(self): + with self.assertRaises(ValueError): + self._make_bonds(settlement_days=np.array([3, 3])) + + def test_mismatched_face_amounts_length(self): + with self.assertRaises(ValueError): + self._make_bonds(face_amounts=np.array([100.0])) + + def test_mismatched_coupons_length(self): + with self.assertRaises(ValueError): + self._make_bonds(coupons=np.array([0.05, 0.04])) + + def test_mismatched_issue_dates_length(self): + with self.assertRaises(ValueError): + self._make_bonds( + issue_dates=np.array(['2006-07-10'], dtype='datetime64[D]')) + + def test_invalid_coupons_ndim(self): + with self.assertRaises(ValueError): + self._make_bonds(coupons=np.ones((3, 2, 1))) + + def test_slice_preserves_properties(self): + """Sliced FixedRateBonds should carry the right subset of data.""" + bonds = self._make_bonds() + sub = bonds[1:3] + np.testing.assert_array_equal(sub.settlement_days, self.settlement_days[1:3]) + np.testing.assert_array_equal(sub.face_amounts, self.face_amounts[1:3]) + np.testing.assert_array_equal(sub.coupons, self.coupons[1:3]) + np.testing.assert_array_equal(sub.redemptions, np.array([100.0, 100.0])) + np.testing.assert_array_equal(sub.issue_dates, self.issue_dates[1:3]) + + def test_negative_index_bounds(self): + bonds = self._make_bonds() + with self.assertRaises(IndexError): + bonds[-4] + + +if __name__ == '__main__': + unittest.main()