From 40c7fc9c5346c020b62a40310b21b80da0169be6 Mon Sep 17 00:00:00 2001 From: Lucas Morin Date: Sun, 11 Oct 2026 11:35:25 +0200 Subject: [PATCH 1/2] DOC: demonstrate pmt broadcasting across loan portfolios --- benchmarks/benchmarks.py | 24 ++++++++++++++++++++++++ doc/source/index.rst | 1 + doc/source/portfolio.rst | 29 +++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 doc/source/portfolio.rst diff --git a/benchmarks/benchmarks.py b/benchmarks/benchmarks.py index 8d074f3..e42ef1d 100644 --- a/benchmarks/benchmarks.py +++ b/benchmarks/benchmarks.py @@ -29,3 +29,27 @@ def time_for_loop(self, n_cashflows, cashflow_lengths, rates_lengths): def time_broadcast(self, n_cashflows, cashflow_lengths, rates_lengths): npf.npv(self.rates, self.cashflows) + + +class MortgageScenarios: + """Compare per-loan calls with existing pmt broadcasting.""" + + param_names = ["loans", "scenarios"] + params = [(100, 1000, 10000), (1, 5)] + + def setup(self, loans, scenarios): + self.principal = np.linspace(75000, 450000, loans) + self.periods = np.resize([120, 180, 240, 300, 360], loans) + annual_rates = np.linspace(0.03, 0.065, loans) + shifts = np.linspace(-0.01, 0.01, scenarios) if scenarios > 1 else [0] + self.rates = (annual_rates[:, None] + np.asarray(shifts)[None, :]) / 12 + + def time_individual_calls(self, loans, scenarios): + return np.array([ + [npf.pmt(self.rates[i, j], self.periods[i], self.principal[i]) + for j in range(scenarios)] + for i in range(loans) + ]) + + def time_broadcast(self, loans, scenarios): + return npf.pmt(self.rates, self.periods[:, None], self.principal[:, None]) diff --git a/doc/source/index.rst b/doc/source/index.rst index 352631f..40dfd87 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -25,6 +25,7 @@ alias is `npf`. For example, :maxdepth: 4 api + portfolio dev/index diff --git a/doc/source/portfolio.rst b/doc/source/portfolio.rst new file mode 100644 index 0000000..81563ab --- /dev/null +++ b/doc/source/portfolio.rst @@ -0,0 +1,29 @@ +Loans and compounding +===================== + +Multiple loans and rate scenarios +--------------------------------- + +``pmt`` already broadcasts its inputs. Put loans on rows and rate scenarios on +columns. Rates below are nominal annual rates, compounded monthly; fees are +excluded. Payments are returned as negative cash flows for positive loans. + +.. code-block:: python + + import numpy as np + import numpy_financial as npf + + principal = np.array([250000, 15000, 20000]) + months = np.array([300, 48, 48]) + annual_rate = np.array([0.05, 0.08, 0.07]) + rate_shifts = np.array([-0.01, 0, 0.01]) + monthly_rates = (annual_rate[:, None] + rate_shifts[None, :]) / 12 + payments = npf.pmt(monthly_rates, months[:, None], principal[:, None]) + # payments.shape == (3, 3): three loans, three scenarios + +For one rate per loan, pass one-dimensional arrays of the same length. Do not +add a scenario axis unless the cross-product of loans and scenarios is wanted. +For very large portfolios, process slices of loans to limit temporary memory. +Keep full precision internally and round only where a contractual schedule +requires it. + From 0e37d646fbdc53a9dd8a29ab538225699918027a Mon Sep 17 00:00:00 2001 From: Lucas Morin Date: Sun, 11 Oct 2026 11:37:10 +0200 Subject: [PATCH 2/2] Tidy portfolio documentation --- doc/source/portfolio.rst | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/doc/source/portfolio.rst b/doc/source/portfolio.rst index 81563ab..e34d7ac 100644 --- a/doc/source/portfolio.rst +++ b/doc/source/portfolio.rst @@ -1,5 +1,5 @@ -Loans and compounding -===================== +Loan portfolios +=============== Multiple loans and rate scenarios --------------------------------- @@ -26,4 +26,3 @@ add a scenario axis unless the cross-product of loans and scenarios is wanted. For very large portfolios, process slices of loans to limit temporary memory. Keep full precision internally and round only where a contractual schedule requires it. -