Repository: numpy/numpy-financial

Issue:
Slow NPV calculations for monte carlo simulation purposes
When using a large number of monte carlo simulations (~1e6 or more) for uncertainty calculations on NPV, the present implementation of Numpy-financial NPV is very slow. We suggest a new implementation of the NPV function , which allows calculation of serveral projects simultaneously, and is approximately 200 times faster than the current version for 1e6 simulations for a cash-flow of length 10.

In cases where the number of entries in the cash-flow is significantly higher than the number of projects to be calculated, the old implementation will be faster. To solve this, we suggest an adaptive approach which chooses method based on the input dimensions of the 'values' array.

Example code:
```
import numpy_financial as npf
import numpy as np
import time

def faster_npv(rate: float, values: list):
    """ A faster way to calculate NPV for several projects based 
    on numpy arrays. """
    discounted_values = []
    for i in range(np.shape(values)[0]):
        discounted_values.append(values[i] / (1 + rate) ** i)
    return np.sum(discounted_values, axis=0)

rate=0.05
no_simulations = int(1e6)

capex = np.random.normal(loc=10e5, scale = 10e3, size=no_simulations)
lifetime = 10 #years

opex = []
for yr in range(lifetime):
    opex.append(np.random.normal(loc=10e4, scale= 10e2,size=no_simulations))

start_time=time.time()
all_npv=np.nan*np.ones_like(capex)
for i in range(no_simulations):
    values = [capex[i]]+[op[i] for op in opex]
    all_npv[i]=npf.npv(rate, values)
print(f"Standard NPF calculations took {time.time()-start_time} s.")

start_time=time.time()
values=[capex]+opex
faster_npv(rate,values )
print(f"Faster NPV calculations took {time.time()-start_time} s.")
```


Comments:

Patch:
diff --git a/numpy_financial/_financial.py b/numpy_financial/_financial.py
index e268424..be3fb67 100644
--- a/numpy_financial/_financial.py
+++ b/numpy_financial/_financial.py
@@ -849,8 +849,15 @@ def npv(rate, values):
     3065.22267
 
     """
-    values = np.asarray(values)
-    return (values / (1+rate)**np.arange(0, len(values))).sum(axis=0)
+    values = np.atleast_2d(values)
+    timestep_array = np.arange(0, values.shape[1])
+    npv = (values / (1 + rate) ** timestep_array).sum(axis=1)
+    try:
+        # If size of array is one, return scalar
+        return npv.item()
+    except ValueError:
+        # Otherwise, return entire array
+        return npv
 
 
 def mirr(values, finance_rate, reinvest_rate):


Test patch:
diff --git a/numpy_financial/tests/test_financial.py b/numpy_financial/tests/test_financial.py
index 791f1a7..ff91282 100644
--- a/numpy_financial/tests/test_financial.py
+++ b/numpy_financial/tests/test_financial.py
@@ -148,6 +148,19 @@ class TestNpv:
             npf.npv(Decimal('0.05'), [-15000, 1500, 2500, 3500, 4500, 6000]),
             Decimal('122.894854950942692161628715'))
 
+    def test_npv_broadcast(self):
+        cashflows = [
+            [-15000, 1500, 2500, 3500, 4500, 6000],
+            [-15000, 1500, 2500, 3500, 4500, 6000],
+            [-15000, 1500, 2500, 3500, 4500, 6000],
+            [-15000, 1500, 2500, 3500, 4500, 6000],
+        ]
+        expected_npvs = [
+            122.8948549, 122.8948549, 122.8948549, 122.8948549
+        ]
+        actual_npvs = npf.npv(0.05, cashflows)
+        assert_allclose(actual_npvs, expected_npvs)
+
 
 class TestPmt:
     def test_pmt_simple(self):