financial_calculator 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +7 -23
- data/financial_calculator.gemspec +3 -2
- data/lib/financial_calculator.rb +10 -6
- data/lib/financial_calculator/amortization.rb +0 -2
- data/lib/financial_calculator/irr.rb +100 -0
- data/lib/financial_calculator/npv.rb +44 -0
- data/lib/financial_calculator/pv.rb +85 -0
- data/lib/financial_calculator/rates.rb +0 -2
- data/lib/financial_calculator/transaction.rb +0 -2
- data/lib/financial_calculator/version.rb +1 -1
- data/lib/financial_calculator/xirr.rb +124 -0
- data/lib/financial_calculator/xnpv.rb +91 -0
- data/spec/irr_spec.rb +44 -0
- data/spec/npv_spec.rb +39 -0
- data/spec/pv_spec.rb +127 -0
- data/spec/spec_helper.rb +10 -9
- data/spec/xirr_spec.rb +73 -0
- data/spec/xnpv_spec.rb +81 -0
- metadata +33 -10
- data/HISTORY +0 -47
- data/lib/financial_calculator/cashflows.rb +0 -127
- data/lib/financial_calculator/decimal.rb +0 -21
- data/spec/cashflows_spec.rb +0 -66
- data/spec/decimal_spec.rb +0 -24
@@ -0,0 +1,91 @@
|
|
1
|
+
module FinancialCalculator
|
2
|
+
# Calculate the net present value of a series of unequally spaced, (potentially) unequal cashflows
|
3
|
+
class Xnpv
|
4
|
+
# @return [Numeric] The rates used for calculating the present value
|
5
|
+
# @api public
|
6
|
+
attr_reader :rate
|
7
|
+
|
8
|
+
# @return [Array<Numeric>] An array of cashflow amounts
|
9
|
+
# @api public
|
10
|
+
attr_reader :cashflows
|
11
|
+
|
12
|
+
# @return [Array<Date>] An array of dates on which the cashflows occur
|
13
|
+
# @api public
|
14
|
+
attr_reader :dates
|
15
|
+
|
16
|
+
# @return [DecNum] Result of the XNPV calculation
|
17
|
+
# @api public
|
18
|
+
attr_reader :result
|
19
|
+
|
20
|
+
# Allows for creating an XNPV calculation with an array of any objects that respond to #amount and #date
|
21
|
+
# @param [Numeric] rate The discount (interest) rate
|
22
|
+
# @param [Array<Transaction>] transaction An array of Transaction objects.
|
23
|
+
# @return [FinancialCalculator::Xnpv]
|
24
|
+
# @api public
|
25
|
+
def self.with_transactions(rate, transactions)
|
26
|
+
raise ArgumentError.new("Argument \"rate\" must be a Numeric. Got #{rate.class} instead") unless rate.is_a? Numeric
|
27
|
+
unless transactions.all? { |t| t.is_a? Transaction }
|
28
|
+
raise ArgumentError.new("Argument \"transactions\" must be an array of Transaction")
|
29
|
+
end
|
30
|
+
|
31
|
+
cashflows = transactions.map(&:amount)
|
32
|
+
dates = transactions.map(&:date)
|
33
|
+
|
34
|
+
self.new(rate, cashflows, dates)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Create a new XNPV calculation
|
38
|
+
# @param [Numeric] rate The discount (interest) rate
|
39
|
+
# @param [Array<Numeric>] cashflows An array of cashflows
|
40
|
+
# @param [Array<Date>] dates An array of dates representing when each cashflow occurs
|
41
|
+
# @return [FinancialCalculator::Xnpv]
|
42
|
+
# @raise [ArgumentError] When cashflows and dates are not the same length
|
43
|
+
# @raise [ArgumentError] When dates is not an array of Date
|
44
|
+
# @api public
|
45
|
+
def initialize(rate, cashflows, dates)
|
46
|
+
validate_cashflows_dates_size(cashflows, dates)
|
47
|
+
validate_dates(dates)
|
48
|
+
|
49
|
+
@rate = Flt::DecNum(rate.to_s)
|
50
|
+
@cashflows = cashflows.map { |cashflow| Flt::DecNum(cashflow.to_s) }.freeze
|
51
|
+
@dates = dates.freeze
|
52
|
+
@result = solve(rate, cashflows, dates)
|
53
|
+
end
|
54
|
+
|
55
|
+
# @api public
|
56
|
+
def inspect
|
57
|
+
"XNPV(#{result})"
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
# Solve the NPV calcluation.
|
63
|
+
# @note This methods uses a 365 day year regardless of whether or not the
|
64
|
+
# year is actually a leap year. This is done in order to maintain
|
65
|
+
# consistency with common implementations such as Excel and Google Sheets.
|
66
|
+
# In the future it would be nice to have a flag that allows for using
|
67
|
+
# the actual number of days in the year
|
68
|
+
def solve(rate, cashflows, dates)
|
69
|
+
start = dates[0]
|
70
|
+
transactions = cashflows.zip(dates)
|
71
|
+
amount_index = 0
|
72
|
+
date_index = 1
|
73
|
+
|
74
|
+
transactions.reduce(0) do |total, transaction|
|
75
|
+
total += transaction[amount_index] / ((1 + @rate) ** (Flt::DecNum(transaction[date_index] - start) / 365))
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def validate_dates(dates)
|
80
|
+
unless dates.all? { |date| date.is_a? Date }
|
81
|
+
raise ArgumentError.new("Argument dates must be an array of Date")
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def validate_cashflows_dates_size(cashflows, dates)
|
86
|
+
unless dates.length == cashflows.length
|
87
|
+
raise ArgumentError.new("Arguments cashflows and dates must be the same length")
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
data/spec/irr_spec.rb
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
require_relative 'spec_helper'
|
2
|
+
|
3
|
+
describe "Irr" do
|
4
|
+
let(:flows) { [-10, 20, 30, 40] }
|
5
|
+
let(:irr) { Irr.new(flows) }
|
6
|
+
|
7
|
+
describe '#result' do
|
8
|
+
subject { irr.result }
|
9
|
+
|
10
|
+
it { is_expected.to be_a Flt::DecNum }
|
11
|
+
|
12
|
+
context 'when all values are positive' do
|
13
|
+
let(:flows) { [10, 20, 30, 40] }
|
14
|
+
it_behaves_like 'the values do not converge'
|
15
|
+
end
|
16
|
+
|
17
|
+
context 'when all values are negative' do
|
18
|
+
let(:flows) { [-10, -20, -30, -40] }
|
19
|
+
it_behaves_like 'the values do not converge'
|
20
|
+
end
|
21
|
+
|
22
|
+
context 'when the leading value is zero' do
|
23
|
+
describe 'all other values are positive' do
|
24
|
+
let(:flows) { [0, 10, 20, 30] }
|
25
|
+
it_behaves_like 'the values do not converge'
|
26
|
+
end
|
27
|
+
|
28
|
+
describe 'all other values are negative' do
|
29
|
+
let(:flows) { [0, -10, -20, -30] }
|
30
|
+
it_behaves_like 'the values do not converge'
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
describe '#inspect' do
|
35
|
+
subject { irr.inspect }
|
36
|
+
|
37
|
+
it { is_expected.to be_a String }
|
38
|
+
it { is_expected.to include 'IRR' }
|
39
|
+
it 'includes the result of the calculation' do
|
40
|
+
expect(subject).to include irr.result.to_s
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
data/spec/npv_spec.rb
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
require_relative 'spec_helper'
|
2
|
+
|
3
|
+
describe "Npv" do
|
4
|
+
let(:rate) { 0.1 }
|
5
|
+
let(:cashflows) { [10] * 4 }
|
6
|
+
let(:npv) { Npv.new(rate, cashflows) }
|
7
|
+
|
8
|
+
subject { Npv.new(rate, cashflows) }
|
9
|
+
|
10
|
+
it 'has a rate attribute' do
|
11
|
+
expect(subject).to have_attributes(rate: rate)
|
12
|
+
end
|
13
|
+
|
14
|
+
it 'has an cashflows attribute' do
|
15
|
+
expect(subject).to have_attributes(cashflows: cashflows)
|
16
|
+
end
|
17
|
+
|
18
|
+
it 'has a result attribute' do
|
19
|
+
expect(subject.result).to be_a Numeric
|
20
|
+
end
|
21
|
+
|
22
|
+
context 'when provided a non-numeric rate' do
|
23
|
+
let(:rate) { 'string' }
|
24
|
+
|
25
|
+
it 'raises an ArgumentError' do
|
26
|
+
expect { subject }.to raise_error ArgumentError
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
describe '#inspect' do
|
31
|
+
subject { npv.inspect }
|
32
|
+
|
33
|
+
it { is_expected.to be_a String }
|
34
|
+
it { is_expected.to include 'NPV' }
|
35
|
+
it 'includes the result of the Npv calculation' do
|
36
|
+
expect(subject).to include npv.result.to_s
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
data/spec/pv_spec.rb
ADDED
@@ -0,0 +1,127 @@
|
|
1
|
+
require_relative 'spec_helper'
|
2
|
+
|
3
|
+
shared_examples_for 'it has invalid present value arguments' do
|
4
|
+
it 'raises an ArgumentError' do
|
5
|
+
expect { subject }.to raise_error ArgumentError
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
describe "Pv" do
|
10
|
+
let(:rate) { 0.1 }
|
11
|
+
let(:num_periods) { 10 }
|
12
|
+
let(:payment) { -100 }
|
13
|
+
let(:future_value) { 0 }
|
14
|
+
let(:pays_at_beginning) { false }
|
15
|
+
let(:present_value) { Pv.new(rate, num_periods, payment) }
|
16
|
+
|
17
|
+
subject { present_value }
|
18
|
+
|
19
|
+
it 'has a rate attribute' do
|
20
|
+
expect(subject).to have_attributes(rate: rate)
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'has a num_periods attribute' do
|
24
|
+
expect(subject).to have_attributes(num_periods: num_periods)
|
25
|
+
end
|
26
|
+
|
27
|
+
it 'has a payment attribute' do
|
28
|
+
expect(subject).to have_attributes(payment: payment)
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'has a future_value attribute that defaults to 0' do
|
32
|
+
expect(subject).to have_attributes(future_value: 0)
|
33
|
+
end
|
34
|
+
|
35
|
+
it 'has a pays_at_beginning? attribute that defaults to false' do
|
36
|
+
expect(subject).to have_attributes(pays_at_beginning?: false)
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'has a result attribute whose value is positive' do
|
40
|
+
expect(subject).to have_attributes(result: (a_value > 0))
|
41
|
+
end
|
42
|
+
|
43
|
+
context 'when the payment is positive' do
|
44
|
+
let(:payment) { 100 }
|
45
|
+
|
46
|
+
it 'has a result attribute whose value is negative' do
|
47
|
+
expect(subject).to have_attributes(result: (a_value < 0))
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
context 'when the payment is 0' do
|
52
|
+
let(:payment) { 0 }
|
53
|
+
|
54
|
+
it 'has a present value of 0' do
|
55
|
+
expect(subject.result).to eql 0
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
context 'when the number of periods is negative' do
|
60
|
+
let(:num_periods) { -1 }
|
61
|
+
it_behaves_like 'it has invalid present value arguments'
|
62
|
+
end
|
63
|
+
|
64
|
+
context 'when rate is non-numeric' do
|
65
|
+
let(:rate) { 'string' }
|
66
|
+
it_behaves_like 'it has invalid present value arguments'
|
67
|
+
end
|
68
|
+
|
69
|
+
context 'when num_periods is non-numeric' do
|
70
|
+
let(:num_periods) { 'string' }
|
71
|
+
it_behaves_like 'it has invalid present value arguments'
|
72
|
+
end
|
73
|
+
|
74
|
+
context 'when payment is non-numeric' do
|
75
|
+
let(:payment) { 'string' }
|
76
|
+
it_behaves_like 'it has invalid present value arguments'
|
77
|
+
end
|
78
|
+
|
79
|
+
context 'when future_values non-numeric' do
|
80
|
+
let(:future_value) { 'string' }
|
81
|
+
|
82
|
+
subject { Pv.new(rate, num_periods, payment, future_value) }
|
83
|
+
|
84
|
+
it_behaves_like 'it has invalid present value arguments'
|
85
|
+
end
|
86
|
+
|
87
|
+
context 'when the number of periods is 0' do
|
88
|
+
let(:num_periods) { 0 }
|
89
|
+
|
90
|
+
it 'has a result equal to the future value' do
|
91
|
+
expect(subject.result).to eql future_value
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
context 'when provided with a future value' do
|
96
|
+
let(:future_value) { 100 }
|
97
|
+
|
98
|
+
subject { Pv.new(rate, num_periods, payment, future_value) }
|
99
|
+
|
100
|
+
it { is_expected.to have_attributes(future_value: future_value) }
|
101
|
+
end
|
102
|
+
|
103
|
+
context 'when payments occur at the beginning of each period' do
|
104
|
+
let(:pays_at_beginning) { true }
|
105
|
+
|
106
|
+
subject { Pv.new(rate, num_periods, payment, 0, pays_at_beginning) }
|
107
|
+
|
108
|
+
it { is_expected.to have_attributes(pays_at_beginning?: true) }
|
109
|
+
end
|
110
|
+
|
111
|
+
describe '#inspect' do
|
112
|
+
|
113
|
+
subject { present_value.inspect }
|
114
|
+
|
115
|
+
it { is_expected.to be_a String }
|
116
|
+
it { is_expected.to include 'PV' }
|
117
|
+
it 'includes the result of the present value calculation' do
|
118
|
+
expect(subject).to include present_value.result.to_s
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
describe '#result' do
|
123
|
+
subject { present_value.result }
|
124
|
+
|
125
|
+
it {is_expected.to be_a Flt::DecNum }
|
126
|
+
end
|
127
|
+
end
|
data/spec/spec_helper.rb
CHANGED
@@ -1,14 +1,12 @@
|
|
1
1
|
require 'simplecov'
|
2
2
|
require 'coveralls'
|
3
3
|
|
4
|
-
Coveralls.wear!
|
4
|
+
# Coveralls.wear!
|
5
5
|
SimpleCov.start do
|
6
|
-
|
6
|
+
add_filter "/spec/"
|
7
|
+
minimum_coverage 100
|
7
8
|
end
|
8
9
|
|
9
|
-
require 'minitest/autorun'
|
10
|
-
require 'minitest/spec'
|
11
|
-
|
12
10
|
require 'active_support/all'
|
13
11
|
|
14
12
|
require 'pry'
|
@@ -16,8 +14,11 @@ require 'pry'
|
|
16
14
|
require 'flt'
|
17
15
|
require 'flt/d'
|
18
16
|
|
19
|
-
require_relative '../lib/financial_calculator
|
20
|
-
require_relative '../lib/financial_calculator/cashflows.rb'
|
21
|
-
require_relative '../lib/financial_calculator/rates.rb'
|
22
|
-
require_relative '../lib/financial_calculator/transaction.rb'
|
17
|
+
require_relative '../lib/financial_calculator'
|
23
18
|
include FinancialCalculator
|
19
|
+
|
20
|
+
shared_examples_for 'the values do not converge' do
|
21
|
+
it 'should raise an ArgumentError' do
|
22
|
+
expect { subject }.to raise_error ArgumentError
|
23
|
+
end
|
24
|
+
end
|
data/spec/xirr_spec.rb
ADDED
@@ -0,0 +1,73 @@
|
|
1
|
+
require_relative 'spec_helper'
|
2
|
+
|
3
|
+
describe "Xirr" do
|
4
|
+
let(:flows) { [-10, 20, 30, 40] }
|
5
|
+
let(:dates) do
|
6
|
+
[
|
7
|
+
Date.new(2018, 1, 1),
|
8
|
+
Date.new(2018, 6, 1),
|
9
|
+
Date.new(2018, 9, 1),
|
10
|
+
Date.new(2018, 12, 1)
|
11
|
+
]
|
12
|
+
end
|
13
|
+
|
14
|
+
describe '::with_transactions' do
|
15
|
+
let(:transactions) do
|
16
|
+
flows.zip(dates).map { |flow_and_date| Transaction.new(flow_and_date[0], date: flow_and_date[1]) }
|
17
|
+
end
|
18
|
+
|
19
|
+
subject { Xirr.with_transactions(transactions) }
|
20
|
+
|
21
|
+
it 'returns an Xirr object' do
|
22
|
+
expect(subject).to be_a Xirr
|
23
|
+
end
|
24
|
+
|
25
|
+
context 'the array contains objects other than Transaction' do
|
26
|
+
let(:transactions) { flows }
|
27
|
+
|
28
|
+
it 'raises an ArgumentError' do
|
29
|
+
expect { subject }.to raise_error ArgumentError
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
describe '#result' do
|
35
|
+
subject { Xirr.new(flows, dates).result }
|
36
|
+
|
37
|
+
it { is_expected.to be_a Flt::DecNum }
|
38
|
+
|
39
|
+
context 'when all values are positive' do
|
40
|
+
let(:flows) { [10, 20, 30, 40] }
|
41
|
+
let(:flows) { [10, 20, 30, 40] }
|
42
|
+
it_behaves_like 'the values do not converge'
|
43
|
+
end
|
44
|
+
|
45
|
+
context 'when all values are negative' do
|
46
|
+
let(:flows) { [-10, -20, -30, -40] }
|
47
|
+
it_behaves_like 'the values do not converge'
|
48
|
+
end
|
49
|
+
|
50
|
+
context 'when the leading value is zero' do
|
51
|
+
describe 'all other values are positive' do
|
52
|
+
let(:flows) { [0, 10, 20, 30] }
|
53
|
+
it_behaves_like 'the values do not converge'
|
54
|
+
end
|
55
|
+
|
56
|
+
describe 'all other values are negative' do
|
57
|
+
let(:flows) { [0, -10, -20, -30] }
|
58
|
+
it_behaves_like 'the values do not converge'
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
describe '#inspect' do
|
64
|
+
let(:xirr) { Xirr.new(flows, dates) }
|
65
|
+
subject { xirr.inspect }
|
66
|
+
|
67
|
+
it { is_expected.to be_a String }
|
68
|
+
it { is_expected.to include 'XIRR' }
|
69
|
+
it 'includes the result of the calculation' do
|
70
|
+
expect(subject).to include xirr.result.to_s
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
data/spec/xnpv_spec.rb
ADDED
@@ -0,0 +1,81 @@
|
|
1
|
+
require_relative 'spec_helper'
|
2
|
+
|
3
|
+
describe "Xnpv" do
|
4
|
+
let(:cashflows) { [1000] * 5 }
|
5
|
+
let(:year) { 2017 }
|
6
|
+
let(:rate) { 0.02 }
|
7
|
+
let(:xnpv) { Xnpv.new(rate, cashflows, dates) }
|
8
|
+
let(:dates) do
|
9
|
+
[
|
10
|
+
Date.new(year, 1, 1),
|
11
|
+
Date.new(year, 3, 1),
|
12
|
+
Date.new(year, 6, 1),
|
13
|
+
Date.new(year, 6, 15),
|
14
|
+
Date.new(year, 6, 30)
|
15
|
+
]
|
16
|
+
end
|
17
|
+
|
18
|
+
subject { xnpv }
|
19
|
+
|
20
|
+
it 'has a cashflows property' do
|
21
|
+
expect(subject).to have_attributes(cashflows: cashflows)
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'has a rate property' do
|
25
|
+
expect(subject).to have_attributes(rate: rate)
|
26
|
+
end
|
27
|
+
|
28
|
+
it 'has a dates property' do
|
29
|
+
expect(subject).to have_attributes(dates: dates)
|
30
|
+
end
|
31
|
+
|
32
|
+
it 'has a result property' do
|
33
|
+
expect(subject.result).to be_a Flt::DecNum
|
34
|
+
end
|
35
|
+
|
36
|
+
context 'when dates and cashflows are not the same size' do
|
37
|
+
let(:transactions) { cashflows[1..-1] }
|
38
|
+
|
39
|
+
subject { Xnpv.new(rate, transactions, dates) }
|
40
|
+
|
41
|
+
it 'raises an ArgumentError' do
|
42
|
+
expect { subject }.to raise_error ArgumentError
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
context 'when the dates array does not contain only dates' do
|
47
|
+
let(:dates) { [Date.today] * 4 + [1] }
|
48
|
+
|
49
|
+
it 'raises an ArgumentError' do
|
50
|
+
expect { subject }.to raise_error ArgumentError
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
describe '::with_transactions' do
|
55
|
+
let(:transactions) { dates.map { |date| Transaction.new(1000, date: date) } }
|
56
|
+
|
57
|
+
subject { Xnpv.with_transactions(rate, transactions) }
|
58
|
+
|
59
|
+
it 'returns an Xnpv object' do
|
60
|
+
expect(subject).to be_a Xnpv
|
61
|
+
end
|
62
|
+
|
63
|
+
context 'the array contains objects other than Transaction' do
|
64
|
+
let(:transactions) { cashflows }
|
65
|
+
|
66
|
+
it 'raises an ArgumentError' do
|
67
|
+
expect { subject }.to raise_error ArgumentError
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
describe '#inspect' do
|
73
|
+
subject { xnpv.inspect }
|
74
|
+
|
75
|
+
it { is_expected.to include 'XNPV' }
|
76
|
+
|
77
|
+
it 'includes the result of the calculation' do
|
78
|
+
expect(subject).to include xnpv.result.to_s
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|