From 6b09ae482f2b4d6759c690ecdeaa5daa7858f3eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 02:14:39 +0000 Subject: [PATCH 1/2] feat: add FixedRateBonds vectorized collection class Add lifelib_pyql.portfolio.instruments.bonds.FixedRateBonds as the plural/vectorized form of lifelib_pyql.instruments.bonds.FixedRateBond, following the same pattern as Schedules is to Schedule. The class stores parallel arrays of bond parameters and reconstructs individual FixedRateBond objects on demand via indexing. Supports int indexing (returns singular FixedRateBond) and slice indexing (returns new FixedRateBonds subset). https://claude.ai/code/session_01JRwjJ46R8sg7EGof6X3ZWH --- lifelib_pyql/portfolio/api.py | 1 + .../portfolio/instruments/__init__.py | 0 lifelib_pyql/portfolio/instruments/bonds.pxd | 11 + lifelib_pyql/portfolio/instruments/bonds.pyx | 212 ++++++++++++++++++ 4 files changed, 224 insertions(+) create mode 100644 lifelib_pyql/portfolio/instruments/__init__.py create mode 100644 lifelib_pyql/portfolio/instruments/bonds.pxd create mode 100644 lifelib_pyql/portfolio/instruments/bonds.pyx diff --git a/lifelib_pyql/portfolio/api.py b/lifelib_pyql/portfolio/api.py index 3dc8b4fe..54512ea4 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 00000000..e69de29b diff --git a/lifelib_pyql/portfolio/instruments/bonds.pxd b/lifelib_pyql/portfolio/instruments/bonds.pxd new file mode 100644 index 00000000..ca2bd2a9 --- /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 00000000..6fe18735 --- /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"" From 364dd0fc32eadf07529b6cc17d91b3292ec1a3a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Feb 2026 07:25:51 +0000 Subject: [PATCH 2/2] test: add tests for FixedRateBonds vectorized collection Tests cover construction, properties, int/slice/negative indexing, consistency with singular FixedRateBond, scalar and array redemptions, 2D coupons, optional issue_dates, input validation errors, and slice-then-index round-tripping. https://claude.ai/code/session_01JRwjJ46R8sg7EGof6X3ZWH --- test/test_portfolio_bonds.py | 218 +++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 test/test_portfolio_bonds.py diff --git a/test/test_portfolio_bonds.py b/test/test_portfolio_bonds.py new file mode 100644 index 00000000..f5d94d37 --- /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()