Skip to content

Commit 17b7e63

Browse files
committed
explain multiple IRR problem; brentq grid search
1 parent a361c32 commit 17b7e63

File tree

11 files changed

+325
-69
lines changed

11 files changed

+325
-69
lines changed

README.md

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Powered by [github-action-benchmark](https://github.com/rhysd/github-action-benc
3838

3939
Live benchmarks are hosted on [Github Pages](https://anexen.github.io/pyxirr/bench).
4040

41-
# Examples
41+
# Example
4242

4343
```python
4444
from datetime import date
@@ -59,6 +59,69 @@ xirr(dict(zip(dates, amounts)))
5959
xirr(['2020-01-01', '2021-01-01'], [-1000, 1200])
6060
```
6161

62+
# Multiple IRR problem
63+
64+
The multiple internal rates of return problem occur when the signs of cash
65+
flows change more than once. In this case, we say that the project has
66+
non-conventional cash flows. This leads to situation, where it can have more
67+
the one (X)IRR or have no (X)IRR at all.
68+
69+
PyXIRR's approach to the Multiple IRR problem: select the lowest IRR to be conservative
70+
71+
```python
72+
import pandas as pd
73+
import pyxirr
74+
75+
cf = pd.read_csv("tests/samples/30-22.csv", names=["date", "amount"])
76+
# check whether the cash flow is conventional:
77+
print(pyxirr.is_conventional_cash_flow(cf["amount"])) # false
78+
79+
r1 = pyxirr.xirr(cf)
80+
print("r1: ", r1) # -0.31540826742734207
81+
# check using NPV
82+
print("XNPV(r1): ", pyxirr.xnpv(r1, cf)) # -2.3283064365386963e-10
83+
84+
# the second root
85+
r2 = pyxirr.xirr(cf, guess=0.0) # -0.028668460065441048
86+
print("r2: ", r2)
87+
print("XNPV(r2): ", pyxirr.xnpv(r2, cf)) # 0.0
88+
89+
# print NPV profile
90+
import numpy as np
91+
92+
rates = np.linspace(-0.5, 0.5, 50)
93+
values = pyxirr.xnpv(rates, cf)
94+
series = pd.Series(values, index=rates)
95+
96+
print("Zero crossing points:")
97+
for idx in pyxirr.zero_crossing_points(values):
98+
print(series.iloc[[idx, idx+1]])
99+
100+
# NPV changes sign two times:
101+
# 1) between -0.316 and -0.295
102+
# 2) between -0.03 and -0.01
103+
104+
print(series)
105+
# -0.500000 -8.962169e+08
106+
# ...
107+
# -0.336735 -5.895002e+05
108+
# -0.316327 -1.457451e+04
109+
# -0.295918 1.801890e+05
110+
# -0.275510 2.175858e+05
111+
# ...
112+
# -0.051020 2.612340e+03
113+
# -0.030612 1.857505e+02
114+
# -0.010204 -1.452180e+03
115+
# 0.010204 -2.533466e+03
116+
# ...
117+
# 0.500000 -2.358226e+03
118+
119+
# plot NPV profile
120+
pd.DataFrame(series[series > -1e6]).assign(zero=0).plot()
121+
```
122+
123+
# More Examples
124+
62125
### Numpy and Pandas
63126

64127
```python
@@ -99,6 +162,16 @@ xirr(dates, amounts, day_count=DayCount.ACT_360)
99162
xirr(dates, amounts, day_count="30E/360")
100163
```
101164

165+
### Private equity performance metrics
166+
167+
```python
168+
from pyxirr import pe
169+
170+
pe.pme_plus([-20, 15, 0], index=[100, 115, 130], nav=20)
171+
172+
pe.direct_alpha([-20, 15, 0], index=[100, 115, 130], nav=20)
173+
```
174+
102175
### Other financial functions
103176

104177
```python

docs/functions.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,20 @@ CashFlowDict = Dict[DateLike, Amount]
2424
CashFlow = Union[CashFlowSeries, CashFlowTable, CashFlowDict]
2525
```
2626

27+
## Multiple IRR problem
28+
29+
The multiple internal rates of return problem occur when the signs of cash
30+
flows change more than once. In this case, we say that the project has
31+
non-conventional cash flows. This leads to situation, where it can have more
32+
the one (X)IRR or have no (X)IRR at all.
33+
34+
PyXIRR's approach to the Multiple IRR problem: select the lowest IRR to be conservative
35+
36+
See also:
37+
38+
- <https://crystalsofeconomics.wordpress.com/2015/10/25/why-multiple-irr/>
39+
- <http://financialmanagementpro.com/multiple-irr-problem/>
40+
2741
## Day Count Conventions
2842

2943
{% include_relative _inline/day_count_conventions.md %}

pyproject.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,13 @@ include = [
3131
{ path = "Cargo.lock", format = "sdist" },
3232
{ path = ".cargo/*", format = "sdist" },
3333
]
34+
35+
[tool.black]
36+
line-length = 79
37+
target-version = ["py311"]
38+
39+
[tool.isort]
40+
profile = "black"
41+
atomic = true
42+
line_length = 79
43+
lines_after_imports = 2

python/pyxirr/_pyxirr.pyi

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import sys
2+
from collections.abc import Iterable, Sequence
23
from datetime import date, datetime
34
from decimal import Decimal
4-
from collections.abc import Iterable, Sequence
55
from typing import (
66
Any,
77
Dict,
@@ -14,6 +14,7 @@ from typing import (
1414
overload,
1515
)
1616

17+
1718
if sys.version_info >= (3, 8):
1819
from typing import Literal, Protocol
1920
else:
@@ -130,7 +131,11 @@ _ArrayLike = Union[
130131
Sequence[Sequence[Sequence[Sequence[Sequence[_T]]]]],
131132
Sequence[Sequence[Sequence[Sequence[Sequence[Sequence[_T]]]]]],
132133
Sequence[Sequence[Sequence[Sequence[Sequence[Sequence[Sequence[_T]]]]]]],
133-
Sequence[Sequence[Sequence[Sequence[Sequence[Sequence[Sequence[Sequence[_T]]]]]]]],
134+
Sequence[
135+
Sequence[
136+
Sequence[Sequence[Sequence[Sequence[Sequence[Sequence[_T]]]]]]
137+
]
138+
],
134139
]
135140
_ScalarOrArrayLike = Union[_T, _ArrayLike[_T]]
136141

@@ -260,6 +265,7 @@ def pv(
260265
...
261266

262267

268+
@overload
263269
def npv(
264270
rate: _Rate,
265271
amounts: _AmountArray,
@@ -269,6 +275,16 @@ def npv(
269275
...
270276

271277

278+
@overload
279+
def npv(
280+
rate: Iterable[_Rate],
281+
amounts: _AmountArray,
282+
*,
283+
start_from_zero: bool = True,
284+
) -> List[Optional[float]]:
285+
...
286+
287+
272288
@overload
273289
def xnpv(
274290
rate: _Rate,
@@ -292,6 +308,17 @@ def xnpv(
292308
...
293309

294310

311+
@overload
312+
def xnpv(
313+
rate: Iterable[_Rate],
314+
dates: _CashFlow,
315+
*,
316+
silent: bool = False,
317+
day_count: _DayCount = DayCount.ACT_365F,
318+
) -> List[Optional[float]]:
319+
...
320+
321+
295322
@overload
296323
def rate( # type: ignore[misc]
297324
nper: _Period,
@@ -482,3 +509,11 @@ def xirr(
482509
day_count: _DayCount = DayCount.ACT_365F,
483510
) -> Optional[float]:
484511
...
512+
513+
514+
def is_conventional_cash_flow(cf: _AmountArray) -> bool:
515+
...
516+
517+
518+
def zero_crossing_points(cf: _AmountArray) -> list[int]:
519+
...

src/core/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@ mod scheduled;
77

88
pub use models::{DateLike, InvalidPaymentsError};
99
pub use periodic::*;
10-
pub use scheduled::{days_between, xfv, xirr, xnfv, xnpv, year_fraction, DayCount};
10+
pub use scheduled::*;
1111
pub mod private_equity;

src/core/models.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ impl FromStr for DateLike {
4646
}
4747

4848
/// An error returned when the payments do not contain both negative and positive payments.
49-
#[derive(Debug)]
49+
#[derive(Clone, Debug)]
5050
pub struct InvalidPaymentsError(String);
5151

5252
impl InvalidPaymentsError {

src/core/optimize.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
const MAX_ERROR: f64 = 1e-9;
22
const MAX_ITERATIONS: u32 = 50;
33

4-
pub fn newton_raphson<Func, Deriv>(start: f64, f: Func, d: Deriv) -> f64
4+
pub fn newton_raphson<Func, Deriv>(start: f64, f: &Func, d: &Deriv) -> f64
55
where
66
Func: Fn(f64) -> f64,
77
Deriv: Fn(f64) -> f64,
@@ -38,11 +38,12 @@ where
3838

3939
// https://programmingpraxis.com/2012/01/13/excels-xirr-function/
4040

41-
newton_raphson(start, &f, |x: f64| (f(x + MAX_ERROR) - f(x - MAX_ERROR)) / (2.0 * MAX_ERROR))
41+
let df = |x| (f(x + MAX_ERROR) - f(x - MAX_ERROR)) / (2.0 * MAX_ERROR);
42+
newton_raphson(start, &f, &df)
4243
}
4344

4445
// https://github.com/scipy/scipy/blob/39bf11b96f771dcecf332977fb2c7843a9fd55f2/scipy/optimize/Zeros/brentq.c
45-
pub fn brentq<Func>(f: Func, xa: f64, xb: f64, iter: usize) -> f64
46+
pub fn brentq<Func>(f: &Func, xa: f64, xb: f64, iter: usize) -> f64
4647
where
4748
Func: Fn(f64) -> f64,
4849
{
@@ -135,3 +136,16 @@ where
135136

136137
f64::NAN
137138
}
139+
140+
pub fn brentq_grid_search<'a, Func>(
141+
breakpoints: &'a [&[f64]],
142+
f: &'a Func,
143+
) -> impl Iterator<Item = f64> + 'a
144+
where
145+
Func: Fn(f64) -> f64 + 'a,
146+
{
147+
breakpoints
148+
.into_iter()
149+
.flat_map(|x| x.windows(2).map(|pair| brentq(f, pair[0], pair[1], 100)))
150+
.filter(|r| r.is_finite() && f(*r).abs() < 1e-3)
151+
}

src/core/periodic.rs

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use ndarray::{ArrayD, ArrayViewD};
44

55
use super::{
66
models::{validate, InvalidPaymentsError},
7-
optimize::{brentq, newton_raphson, newton_raphson_with_default_deriv},
7+
optimize::{brentq_grid_search, newton_raphson, newton_raphson_with_default_deriv},
88
};
99
use crate::{broadcast_together, broadcasting::BroadcastingError};
1010

@@ -449,23 +449,13 @@ pub fn irr(values: &[f64], guess: Option<f64>) -> Result<f64, InvalidPaymentsErr
449449
return Ok(rate);
450450
}
451451

452-
#[rustfmt::skip]
453-
let breakpoint_list = [
454-
&[0.0, 0.5, 1.0, 1e9],
455-
&[0.0, -0.5, -0.9, -0.99999999999999]
456-
];
452+
// strategy: closest to zero
453+
// let breakpoints: &[f64] = &[0.0, 0.25, -0.25, 0.5, -0.5, 1.0, -0.9, -0.99999999999999, 1e9];
454+
// strategy: pessimistic
455+
let breakpoints: &[f64] = &[-0.99999999999999, -0.75, -0.5, -0.25, 0., 0.25, 0.5, 1.0, 1e6];
456+
let rate = brentq_grid_search(&[breakpoints], &f).next();
457457

458-
for breakpoints in breakpoint_list {
459-
for pair in breakpoints.windows(2) {
460-
let rate = brentq(f, pair[0], pair[1], 100);
461-
462-
if is_good_rate(rate) {
463-
return Ok(rate);
464-
}
465-
}
466-
}
467-
468-
Ok(f64::NAN)
458+
Ok(rate.unwrap_or(f64::NAN))
469459
}
470460

471461
pub fn mirr(

src/core/scheduled/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@ mod xirr;
33
mod xnfv;
44

55
pub use day_count::{days_between, year_fraction, DayCount};
6-
pub use xirr::{xirr, xnpv};
7-
pub use xnfv::{xfv, xnfv};
6+
pub use xirr::*;
7+
pub use xnfv::*;

0 commit comments

Comments
 (0)