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.
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
- minimum_coverage 99
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/amortization.rb'
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
@@ -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
@@ -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