finance 0.2.0 → 1.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.
- data/COPYING +2 -0
- data/COPYING.LESSER +2 -0
- data/HISTORY +26 -0
- data/README +24 -22
- data/lib/finance.rb +5 -53
- data/lib/finance/amortization.rb +196 -0
- data/lib/finance/cashflows.rb +55 -0
- data/lib/finance/decimal.rb +12 -0
- data/lib/finance/interval.rb +19 -0
- data/lib/finance/rates.rb +167 -0
- data/lib/finance/transaction.rb +119 -0
- data/test/test_amortization.rb +114 -114
- data/test/test_cashflows.rb +12 -10
- data/test/test_interval.rb +18 -0
- data/test/test_rates.rb +69 -22
- metadata +19 -12
- data/lib/amortization.rb +0 -160
- data/lib/cashflows.rb +0 -33
- data/lib/rates.rb +0 -93
- data/lib/timespan.rb +0 -10
- data/test/test_timespan.rb +0 -12
@@ -0,0 +1,19 @@
|
|
1
|
+
class Integer
|
2
|
+
# convert an integer value representing months into months
|
3
|
+
# @return [Integer] the number of months
|
4
|
+
# @example
|
5
|
+
# 360.months #=> 360
|
6
|
+
# @api public
|
7
|
+
def months
|
8
|
+
self
|
9
|
+
end
|
10
|
+
|
11
|
+
# convert an integer value representing years into months
|
12
|
+
# @return [Integer] the number of months
|
13
|
+
# @example
|
14
|
+
# 30.years #=> 360
|
15
|
+
# @api public
|
16
|
+
def years
|
17
|
+
self * 12
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,167 @@
|
|
1
|
+
require_relative 'decimal'
|
2
|
+
|
3
|
+
module Finance
|
4
|
+
# the Rate class provides an interface for working with interest rates.
|
5
|
+
# {render:Rate#new}
|
6
|
+
# @api public
|
7
|
+
class Rate
|
8
|
+
include Comparable
|
9
|
+
|
10
|
+
# Accepted rate types
|
11
|
+
TYPES = { :apr => "effective",
|
12
|
+
:apy => "effective",
|
13
|
+
:effective => "effective",
|
14
|
+
:nominal => "nominal"
|
15
|
+
}
|
16
|
+
|
17
|
+
# @return [Integer] the duration for which the rate is valid, in months
|
18
|
+
# @api public
|
19
|
+
attr_accessor :duration
|
20
|
+
# @return [DecNum] the effective interest rate
|
21
|
+
# @api public
|
22
|
+
attr_reader :effective
|
23
|
+
# @return [DecNum] the nominal interest rate
|
24
|
+
# @api public
|
25
|
+
attr_reader :nominal
|
26
|
+
|
27
|
+
# compare two Rates, using the effective rate
|
28
|
+
# @return [Numeric] one of -1, 0, +1
|
29
|
+
# @param [Rate] rate the comparison Rate
|
30
|
+
# @example Which is better, a nominal rate of 15% compounded monthly, or 15.5% compounded semiannually?
|
31
|
+
# r1 = Rate.new(0.15, :nominal) #=> Rate.new(0.160755, :apr)
|
32
|
+
# r2 = Rate.new(0.155, :nominal, :compounds => :semiannually) #=> Rate.new(0.161006, :apr)
|
33
|
+
# r1 <=> r2 #=> -1
|
34
|
+
# @api public
|
35
|
+
def <=>(rate)
|
36
|
+
@effective <=> rate.effective
|
37
|
+
end
|
38
|
+
|
39
|
+
# (see #effective)
|
40
|
+
# @api public
|
41
|
+
def apr
|
42
|
+
self.effective
|
43
|
+
end
|
44
|
+
|
45
|
+
# (see #effective)
|
46
|
+
# @api public
|
47
|
+
def apy
|
48
|
+
self.effective
|
49
|
+
end
|
50
|
+
|
51
|
+
# a convenience method which sets the value of @periods
|
52
|
+
# @return none
|
53
|
+
# @param [Symbol, Numeric] input the compounding frequency
|
54
|
+
# @raise [ArgumentError] if input is not an accepted keyword or Numeric
|
55
|
+
# @api private
|
56
|
+
def compounds=(input)
|
57
|
+
@periods = case input
|
58
|
+
when :annually then Flt::DecNum 1
|
59
|
+
when :continuously then Flt::DecNum.infinity
|
60
|
+
when :daily then Flt::DecNum 365
|
61
|
+
when :monthly then Flt::DecNum 12
|
62
|
+
when :quarterly then Flt::DecNum 4
|
63
|
+
when :semiannually then Flt::DecNum 2
|
64
|
+
when Numeric then Flt::DecNum input.to_s
|
65
|
+
else raise ArgumentError
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# set the effective interest rate
|
70
|
+
# @return none
|
71
|
+
# @param [DecNum] rate the effective interest rate
|
72
|
+
# @api private
|
73
|
+
def effective=(rate)
|
74
|
+
@effective = rate
|
75
|
+
@nominal = Rate.to_nominal(rate, @periods)
|
76
|
+
end
|
77
|
+
|
78
|
+
# create a new Rate instance
|
79
|
+
# @return [Rate]
|
80
|
+
# @param [Numeric] rate the decimal value of the interest rate
|
81
|
+
# @param [Symbol] type a valid {TYPES rate type}
|
82
|
+
# @param [optional, Hash] opts set optional attributes
|
83
|
+
# @option opts [String] :duration a time interval for which the rate is valid
|
84
|
+
# @option opts [String] :compounds (:monthly) the number of compounding periods per year
|
85
|
+
# @example create a 3.5% APR rate
|
86
|
+
# Rate.new(0.035, :apr) #=> Rate(0.035, :apr)
|
87
|
+
# @see http://en.wikipedia.org/wiki/Effective_interest_rate
|
88
|
+
# @see http://en.wikipedia.org/wiki/Nominal_interest_rate
|
89
|
+
# @api public
|
90
|
+
def initialize(rate, type, opts={})
|
91
|
+
# Default monthly compounding.
|
92
|
+
opts = { :compounds => :monthly }.merge opts
|
93
|
+
|
94
|
+
# Set optional attributes..
|
95
|
+
opts.each do |key, value|
|
96
|
+
send("#{key}=", value)
|
97
|
+
end
|
98
|
+
|
99
|
+
# Set the rate in the proper way, based on the value of type.
|
100
|
+
begin
|
101
|
+
send("#{TYPES.fetch(type)}=", rate.to_d)
|
102
|
+
rescue KeyError
|
103
|
+
raise ArgumentError, "type must be one of #{TYPES.keys.join(', ')}", caller
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def inspect
|
108
|
+
"Rate.new(#{self.apr.round(6)}, :apr)"
|
109
|
+
end
|
110
|
+
|
111
|
+
# @return [DecNum] the monthly effective interest rate
|
112
|
+
# @example
|
113
|
+
# rate = Rate.new(0.15, :nominal)
|
114
|
+
# rate.apr.round(6) #=> DecNum('0.160755')
|
115
|
+
# rate.monthly.round(6) #=> DecNum('0.013396')
|
116
|
+
# @api public
|
117
|
+
def monthly
|
118
|
+
(self.effective / 12).round(15)
|
119
|
+
end
|
120
|
+
|
121
|
+
# set the nominal interest rate
|
122
|
+
# @return none
|
123
|
+
# @param [DecNum] rate the nominal interest rate
|
124
|
+
# @api private
|
125
|
+
def nominal=(rate)
|
126
|
+
@nominal = rate
|
127
|
+
@effective = Rate.to_effective(rate, @periods)
|
128
|
+
end
|
129
|
+
|
130
|
+
# convert a nominal interest rate to an effective interest rate
|
131
|
+
# @return [DecNum] the effective interest rate
|
132
|
+
# @param [Numeric] rate the nominal interest rate
|
133
|
+
# @param [Numeric] periods the number of compounding periods per year
|
134
|
+
# @example
|
135
|
+
# Rate.to_effective(0.05, 4) #=> DecNum('0.05095')
|
136
|
+
# @api public
|
137
|
+
def Rate.to_effective(rate, periods)
|
138
|
+
rate, periods = rate.to_d, periods.to_d
|
139
|
+
|
140
|
+
if periods.infinite?
|
141
|
+
rate.exp - 1
|
142
|
+
else
|
143
|
+
(1 + rate / periods) ** periods - 1
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# convert an effective interest rate to a nominal interest rate
|
148
|
+
# @return [DecNum] the nominal interest rate
|
149
|
+
# @param [Numeric] rate the effective interest rate
|
150
|
+
# @param [Numeric] periods the number of compounding periods per year
|
151
|
+
# @example
|
152
|
+
# Rate.to_nominal(0.06, 365) #=> DecNum('0.05827')
|
153
|
+
# @see http://www.miniwebtool.com/nominal-interest-rate-calculator/
|
154
|
+
# @api public
|
155
|
+
def Rate.to_nominal(rate, periods)
|
156
|
+
rate, periods = rate.to_d, periods.to_d
|
157
|
+
|
158
|
+
if periods.infinite?
|
159
|
+
(rate + 1).log
|
160
|
+
else
|
161
|
+
periods * ((1 + rate) ** (1 / periods) - 1)
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
private :compounds=, :effective=, :nominal=
|
166
|
+
end
|
167
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
require_relative 'decimal'
|
2
|
+
|
3
|
+
module Finance
|
4
|
+
# the Transaction class provides a general interface for working with individual cash flows.
|
5
|
+
# @api public
|
6
|
+
class Transaction
|
7
|
+
# @return [DecNum] the cash value of the transaction
|
8
|
+
# @api public
|
9
|
+
attr_reader :amount
|
10
|
+
# @return [Integer] the period number of the transaction
|
11
|
+
# @note this attribute is mainly used in the case of mortgage amortization with no dates
|
12
|
+
# @api public
|
13
|
+
attr_accessor :period
|
14
|
+
|
15
|
+
# Set the cash value of the transaction
|
16
|
+
# @return None
|
17
|
+
# @param [Numeric] value the cash value
|
18
|
+
# @example
|
19
|
+
# t = Transaction.new(500)
|
20
|
+
# t.amount = 750
|
21
|
+
# t.amount #=> 750
|
22
|
+
# @api public
|
23
|
+
def amount=(value)
|
24
|
+
@amount = value.to_d
|
25
|
+
end
|
26
|
+
|
27
|
+
# @return [DecNum] the difference between the original transaction
|
28
|
+
# amount and the current amount
|
29
|
+
# @example
|
30
|
+
# t = Transaction.new(500)
|
31
|
+
# t.amount = 750
|
32
|
+
# t.difference #=> DecNum('250')
|
33
|
+
# @api public
|
34
|
+
def difference
|
35
|
+
@amount - @original
|
36
|
+
end
|
37
|
+
|
38
|
+
# create a new Transaction
|
39
|
+
# @return [Transaction]
|
40
|
+
# @param [Numeric] amount the cash value of the transaction
|
41
|
+
# @param [optional, Hash] opts sets optional attributes
|
42
|
+
# @option opts [String] :period the period number of the transaction
|
43
|
+
# @example a simple transaction
|
44
|
+
# t = Transaction.new(400)
|
45
|
+
# @example a transaction with a period number
|
46
|
+
# t = Transaction.new(400, :period => 3)
|
47
|
+
# @api public
|
48
|
+
def initialize(amount, opts={})
|
49
|
+
@amount = amount
|
50
|
+
@original = amount
|
51
|
+
|
52
|
+
# Set optional attributes..
|
53
|
+
opts.each do |key, value|
|
54
|
+
send("#{key}=", value)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# @return [Boolean] whether or not the Transaction is an Interest transaction
|
59
|
+
# @example
|
60
|
+
# pmt = Payment.new(500)
|
61
|
+
# int = Interest.new(500)
|
62
|
+
# pmt.interest? #=> False
|
63
|
+
# int.interest? #=> True
|
64
|
+
# @api public
|
65
|
+
def interest?
|
66
|
+
self.instance_of? Interest
|
67
|
+
end
|
68
|
+
|
69
|
+
# @api public
|
70
|
+
def inspect
|
71
|
+
"Transaction(#{@amount})"
|
72
|
+
end
|
73
|
+
|
74
|
+
# Modify a Transaction's amount by passing a block
|
75
|
+
# @return none
|
76
|
+
# @note self is passed as the argument to the block. This makes any public attribute available.
|
77
|
+
# @example add $100 to a monthly payment
|
78
|
+
# pmt = Payment.new(-500)
|
79
|
+
# pmt.modify { |t| t.amount-100 }
|
80
|
+
# pmt.amount #=> -600
|
81
|
+
# @api public
|
82
|
+
def modify
|
83
|
+
@amount = yield(self)
|
84
|
+
end
|
85
|
+
|
86
|
+
# (see #amount)
|
87
|
+
# @deprecated Provided for backwards compatibility
|
88
|
+
def payment
|
89
|
+
@amount
|
90
|
+
end
|
91
|
+
|
92
|
+
# @return [Boolean] whether or not the Transaction is a Payment transaction
|
93
|
+
# @example
|
94
|
+
# pmt = Payment.new(500)
|
95
|
+
# int = Interest.new(500)
|
96
|
+
# pmt.payment? #=> True
|
97
|
+
# int.payment? #=> False
|
98
|
+
# @api public
|
99
|
+
def payment?
|
100
|
+
self.instance_of? Payment
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
# Represent an interest charge as a Transaction
|
105
|
+
# @see Transaction
|
106
|
+
class Interest < Transaction
|
107
|
+
def inspect
|
108
|
+
"Interest(#{@amount})"
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
# Represent a loan payment as a Transaction
|
113
|
+
# @see Transaction
|
114
|
+
class Payment < Transaction
|
115
|
+
def inspect
|
116
|
+
"Payment(#{@amount})"
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
data/test/test_amortization.rb
CHANGED
@@ -1,156 +1,156 @@
|
|
1
|
-
|
2
|
-
|
1
|
+
require_relative '../lib/finance/amortization.rb'
|
2
|
+
require_relative '../lib/finance/interval.rb'
|
3
|
+
require_relative '../lib/finance/rates.rb'
|
4
|
+
include Finance
|
5
|
+
|
3
6
|
require 'flt/d'
|
4
|
-
require '
|
7
|
+
require 'minitest/unit'
|
8
|
+
require 'shoulda'
|
5
9
|
|
6
|
-
|
7
|
-
|
10
|
+
# @see http://tinyurl.com/6zroqvd for detailed calculations for the
|
11
|
+
# examples in these unit tests.
|
12
|
+
class TestAmortization < Test::Unit::TestCase
|
13
|
+
def ipmt(principal, rate, payment, period)
|
8
14
|
-(-rate*principal*(1+rate)**(period-1) - payment*((1+rate)**(period-1)-1)).round(2)
|
9
15
|
end
|
10
16
|
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
17
|
+
context "a fixed-rate amortization of 200000 at 3.75% over 30 years" do
|
18
|
+
setup do
|
19
|
+
@rate = Rate.new(0.0375, :apr, :duration => 30.years)
|
20
|
+
@principal = D(200000)
|
21
|
+
@std = Amortization.new(@principal, @rate)
|
22
|
+
end
|
16
23
|
|
17
|
-
|
18
|
-
|
19
|
-
|
24
|
+
should "have a principal of $200,000" do
|
25
|
+
assert_equal @principal, @std.principal
|
26
|
+
end
|
20
27
|
|
21
|
-
|
22
|
-
|
23
|
-
|
28
|
+
should "have a final balance of zero" do
|
29
|
+
assert @std.balance.zero?
|
30
|
+
end
|
24
31
|
|
25
|
-
|
26
|
-
|
27
|
-
assert_equal @amortization.interest[period], interest_in_period(@principal, @rate.monthly, @amortization.payment, period+1)
|
32
|
+
should "have a duration of 360 months" do
|
33
|
+
assert_equal 360, @std.duration
|
28
34
|
end
|
29
|
-
end
|
30
35
|
|
31
|
-
|
32
|
-
|
33
|
-
|
36
|
+
should "have a monthly payment of $926.23" do
|
37
|
+
assert_equal D('-926.23'), @std.payment
|
38
|
+
end
|
34
39
|
|
35
|
-
|
36
|
-
|
37
|
-
|
40
|
+
should "have a final payment of $926.96 (due to rounding)" do
|
41
|
+
assert_equal D('-926.96'), @std.payments[-1]
|
42
|
+
end
|
38
43
|
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
payments[-1] = D('-926.96')
|
43
|
-
assert_equal payments, @amortization.payments
|
44
|
-
end
|
44
|
+
should "have total payments of $333,443.53" do
|
45
|
+
assert_equal D('-333443.53'), @std.payments.sum
|
46
|
+
end
|
45
47
|
|
46
|
-
|
47
|
-
|
48
|
-
|
48
|
+
should "have interest charges which agree with the standard formula" do
|
49
|
+
0.upto 359 do |period|
|
50
|
+
assert_equal @std.interest[period], ipmt(@principal, @rate.monthly, @std.payment, period+1)
|
51
|
+
end
|
52
|
+
end
|
49
53
|
|
50
|
-
|
51
|
-
|
54
|
+
should "have total interest charges of $133,433.33" do
|
55
|
+
assert_equal D('133443.53'), @std.interest.sum
|
56
|
+
end
|
52
57
|
end
|
53
58
|
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
59
|
+
context "an adjustable rate amortization of 200000 starting at 3.75% and increasing by 1% every 3 years" do
|
60
|
+
setup do
|
61
|
+
@rates = []
|
62
|
+
0.upto 9 do |adj|
|
63
|
+
@rates << Rate.new(0.0375 + (D('0.01') * adj), :apr, :duration => 3.years)
|
64
|
+
end
|
65
|
+
@principal = D(200000)
|
66
|
+
@arm = Amortization.new(@principal, *@rates)
|
67
|
+
end
|
58
68
|
|
59
|
-
|
60
|
-
|
61
|
-
@rates = []
|
62
|
-
0.upto 9 do |adj|
|
63
|
-
@rates << Rate.new(0.0375 + (D('0.01') * adj), :apr, :duration => 3.years)
|
69
|
+
should "have a principal of $200,000" do
|
70
|
+
assert_equal @principal, @arm.principal
|
64
71
|
end
|
65
|
-
@principal = D(200000)
|
66
|
-
@amortization = Amortization.new(@principal, *@rates)
|
67
|
-
end
|
68
72
|
|
69
|
-
|
70
|
-
|
71
|
-
|
73
|
+
should "have a final balance of zero" do
|
74
|
+
assert @arm.balance.zero?
|
75
|
+
end
|
72
76
|
|
73
|
-
|
74
|
-
|
75
|
-
|
77
|
+
should "have a duration of 360 months" do
|
78
|
+
assert_equal 360, @arm.duration
|
79
|
+
end
|
76
80
|
|
77
|
-
|
78
|
-
|
79
|
-
|
81
|
+
should "not have a fixed monthly payment (since it changes)" do
|
82
|
+
assert_nil @arm.payment
|
83
|
+
end
|
80
84
|
|
81
|
-
|
82
|
-
|
83
|
-
|
85
|
+
should "have payments which increase every three years" do
|
86
|
+
values = %w{926.23 1033.73 1137.32 1235.39 1326.30 1408.27 1479.28 1537.03 1578.84 1601.66 }
|
87
|
+
values.collect!{ |v| -D(v) }
|
88
|
+
|
89
|
+
payments = []
|
90
|
+
values[0,9].each do |v|
|
91
|
+
36.times do
|
92
|
+
payments << v
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
35.times { payments << values[9] }
|
84
97
|
|
85
|
-
|
86
|
-
|
87
|
-
values.collect!{ |v| -D(v) }
|
88
|
-
|
89
|
-
payments = []
|
90
|
-
values[0,9].each do |v|
|
91
|
-
36.times do
|
92
|
-
payments << v
|
98
|
+
payments[0..-2].each_with_index do |payment, index|
|
99
|
+
assert_equal payment, @arm.payments[index]
|
93
100
|
end
|
94
101
|
end
|
95
102
|
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
payments.each_with_index do |payment, index|
|
100
|
-
assert_equal payment, @amortization.payments[index]
|
103
|
+
should "have a final payment of $1601.78 (due to rounding)" do
|
104
|
+
assert_equal D('-1601.78'), @arm.payments[-1]
|
101
105
|
end
|
102
|
-
end
|
103
106
|
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
+
should "have total payments of $47,505.92" do
|
108
|
+
assert_equal D('-477505.92'), @arm.payments.sum
|
109
|
+
end
|
107
110
|
|
108
|
-
|
109
|
-
|
111
|
+
should "have total interest charges of $277,505.92" do
|
112
|
+
assert_equal D('277505.92'), @arm.interest.sum
|
113
|
+
end
|
110
114
|
end
|
111
115
|
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
+
context "a fixed-rate amortization of 200000 at 3.75% over 30 years, where an additional 100 is paid each month" do
|
117
|
+
setup do
|
118
|
+
@rate = Rate.new(0.0375, :apr, :duration => 30.years)
|
119
|
+
@principal = D(200000)
|
120
|
+
@exp = Amortization.new(@principal, @rate){ |period| period.payment - 100 }
|
121
|
+
end
|
116
122
|
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
@principal = D(200000)
|
121
|
-
@amortization = Amortization.new(@principal, @rate){ |period| period.payment - 100 }
|
122
|
-
end
|
123
|
+
should "have a principal of $200,000" do
|
124
|
+
assert_equal @principal, @exp.principal
|
125
|
+
end
|
123
126
|
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
def test_balance
|
129
|
-
assert @amortization.balance.zero?
|
130
|
-
end
|
127
|
+
should "have a final balance of zero" do
|
128
|
+
assert @exp.balance.zero?
|
129
|
+
end
|
131
130
|
|
132
|
-
|
133
|
-
|
134
|
-
|
131
|
+
should "have a duration of 301 months" do
|
132
|
+
assert_equal 301, @exp.duration
|
133
|
+
end
|
135
134
|
|
136
|
-
|
137
|
-
|
138
|
-
|
135
|
+
should "have a monthly payment of $1026.23" do
|
136
|
+
assert_equal D('-1026.23'), @exp.payment
|
137
|
+
end
|
139
138
|
|
140
|
-
|
141
|
-
|
142
|
-
|
139
|
+
should "have a final payment of $1011.09" do
|
140
|
+
assert_equal D('-1011.09'), @exp.payments[-1]
|
141
|
+
end
|
143
142
|
|
144
|
-
|
145
|
-
|
146
|
-
|
143
|
+
should "have total payments of $308,880.09" do
|
144
|
+
assert_equal D('-308880.09'), @exp.payments.sum
|
145
|
+
end
|
147
146
|
|
148
|
-
|
149
|
-
|
150
|
-
|
147
|
+
should "have total additional payments of $30,084.86" do
|
148
|
+
assert_equal D('-30084.86'), @exp.additional_payments.sum
|
149
|
+
end
|
151
150
|
|
152
|
-
|
153
|
-
|
151
|
+
should "have total interest charges of $108880.09" do
|
152
|
+
assert_equal D('108880.09'), @exp.interest.sum
|
153
|
+
end
|
154
154
|
end
|
155
155
|
end
|
156
156
|
|