financial_calculator 2.1.0 → 3.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|