bankroll 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +485 -0
- data/CHANGELOG.md +5 -0
- data/Gemfile +14 -0
- data/Gemfile.lock +81 -0
- data/LICENSE.txt +21 -0
- data/README.md +152 -0
- data/Rakefile +12 -0
- data/bankroll.gemspec +39 -0
- data/bin/bundle +114 -0
- data/bin/console +15 -0
- data/bin/rspec +29 -0
- data/bin/setup +8 -0
- data/lib/bankroll/amortization_schedule.rb +69 -0
- data/lib/bankroll/annuity_factor.rb +25 -0
- data/lib/bankroll/callable.rb +7 -0
- data/lib/bankroll/cumulative_interest.rb +36 -0
- data/lib/bankroll/decimal.rb +59 -0
- data/lib/bankroll/future_value.rb +25 -0
- data/lib/bankroll/interest_payment.rb +37 -0
- data/lib/bankroll/interest_rate.rb +47 -0
- data/lib/bankroll/payment.rb +28 -0
- data/lib/bankroll/present_value.rb +28 -0
- data/lib/bankroll/total_periods.rb +44 -0
- data/lib/bankroll/types.rb +26 -0
- data/lib/bankroll/unpaid_balance.rb +42 -0
- data/lib/bankroll/version.rb +5 -0
- data/lib/bankroll.rb +70 -0
- metadata +116 -0
data/README.md
ADDED
@@ -0,0 +1,152 @@
|
|
1
|
+
# Bankroll
|
2
|
+
|
3
|
+
Bankroll is a set of financial calculations for mortgages and loans.
|
4
|
+
|
5
|
+
The goal of this library is to make time value of money calculations
|
6
|
+
understandable and decomposed into well written functions.
|
7
|
+
|
8
|
+
I find most financial math libraries to be somewhat opaque so hopefully
|
9
|
+
this helps when learning how to calculate mortgages and other annuity based
|
10
|
+
investments.
|
11
|
+
|
12
|
+
Features:
|
13
|
+
- Uses safe math with BigDecimal through the Bankroll::Decimal api
|
14
|
+
- Handles only ordinary annuities (annuities due at the END of the period)
|
15
|
+
- Implements most excel functions using some inspiration from Exonio gem
|
16
|
+
|
17
|
+
## Installation
|
18
|
+
|
19
|
+
Add this line to your application's Gemfile:
|
20
|
+
|
21
|
+
```ruby
|
22
|
+
gem 'bankroll'
|
23
|
+
```
|
24
|
+
|
25
|
+
And then execute:
|
26
|
+
|
27
|
+
$ bundle install
|
28
|
+
|
29
|
+
Or install it yourself as:
|
30
|
+
|
31
|
+
$ gem install bankroll
|
32
|
+
|
33
|
+
## Usage
|
34
|
+
|
35
|
+
```ruby
|
36
|
+
|
37
|
+
# What is the monthly payment?
|
38
|
+
payment = Bankroll.payment(
|
39
|
+
periods: 360,
|
40
|
+
interest_rate: 0.01 / 12.0,
|
41
|
+
present_value: 1_000_000
|
42
|
+
).round
|
43
|
+
puts payment #=> Bankroll::Decimal["3_216.40"]
|
44
|
+
|
45
|
+
# What was the original loan amount?
|
46
|
+
original_amount = Bankroll.present_value(
|
47
|
+
periods: 360, # 30 years
|
48
|
+
monthly_payment: 3_216.40,
|
49
|
+
interest_rate: 0.01 / 12.0, # 2.5%
|
50
|
+
).round
|
51
|
+
puts original_amount #=> Bankroll::Decimal["1_000_001.49"]
|
52
|
+
|
53
|
+
# What does the balance look like after a single mortgage payment?
|
54
|
+
unpaid_balance = Bankroll.unpaid_balance(
|
55
|
+
present_value: 1_000_000,
|
56
|
+
interest_rate: 0.01 / 12.0,
|
57
|
+
periods: 360,
|
58
|
+
period: 1,
|
59
|
+
payment: 3_216.40
|
60
|
+
).round
|
61
|
+
puts unpaid_balance #=> Bankroll::Decimal["997_616.94"]
|
62
|
+
|
63
|
+
# What was the interest rate?
|
64
|
+
interest_rate = Bankroll.interest_rate(
|
65
|
+
present_value: 1_000_000,
|
66
|
+
periods: 360,
|
67
|
+
payment: -3_216.40,
|
68
|
+
).round(3)
|
69
|
+
puts interest_rate #=> Bankroll::Decimal["0.833e-3"]
|
70
|
+
|
71
|
+
# What is the annuity factor of a rate for n periods?
|
72
|
+
annuity_factor = Bankroll.annuity_factor(
|
73
|
+
interest_rate: 0.01,
|
74
|
+
periods: 2
|
75
|
+
).round(4)
|
76
|
+
puts annuity_factor #=> Bankroll::Decimal["1.9704"]
|
77
|
+
|
78
|
+
# What is the total interest paid on a 500_000 1% mortgage
|
79
|
+
interest_paid = Bankroll.cumulative_interest(
|
80
|
+
periods: 360,
|
81
|
+
interest_rate: 0.01 / 12.0,
|
82
|
+
payment: 1_608.20,
|
83
|
+
present_value: 500_000
|
84
|
+
).round
|
85
|
+
puts interest_paid #=> Bankroll::Decimal["832.34"]
|
86
|
+
|
87
|
+
# What is the total cost of a 500_000 1% loan with interest?
|
88
|
+
total_amount = Bankroll.future_value(
|
89
|
+
present_value: 0,
|
90
|
+
interest_rate: 0.01 / 12.0,
|
91
|
+
periods: 360,
|
92
|
+
payment: 1_608.20
|
93
|
+
).round
|
94
|
+
puts total_amount #=> Bankroll::Decimal["647_846.10"]
|
95
|
+
|
96
|
+
# What is the interest payment on the 17th period of a $165,000 3% mortgage?
|
97
|
+
interest_payment = Bankroll.interest_payment(
|
98
|
+
interest_rate: 0.03 / 12.0,
|
99
|
+
periods: 360,
|
100
|
+
period: 17,
|
101
|
+
present_value: 165_000
|
102
|
+
).round
|
103
|
+
puts interest_payment #=> Bankroll::Decimal["400.96"]
|
104
|
+
|
105
|
+
# How many periods until a -$1000 account with -$100/month reaches $10_000 with
|
106
|
+
# 12% interest rate?
|
107
|
+
total_periods = Bankroll.total_periods(
|
108
|
+
interest_rate: 0.12 / 12.0,
|
109
|
+
payment: -100,
|
110
|
+
present_value: -1_000,
|
111
|
+
future_value: 10_000
|
112
|
+
)
|
113
|
+
puts total_periods #=> Bankroll::Decimal["59.67"]
|
114
|
+
|
115
|
+
# What is the amortization schedule of a $165_000 3% 30 year mortgage?
|
116
|
+
amortization_schedule = Bankroll.amortization_schedule(
|
117
|
+
present_value: 165_000,
|
118
|
+
interest_rate: 0.03 / 12.0,
|
119
|
+
periods: 360
|
120
|
+
)
|
121
|
+
puts amortization_schedule.payments #=> [
|
122
|
+
# Bankroll::AmortizationSchedule::Payment.new(
|
123
|
+
# payment: Bankroll::Decimal["695.65"],
|
124
|
+
# principal: Bankroll::Decimal["283.15"],
|
125
|
+
# interest: Bankroll::Decimal["412.50"],
|
126
|
+
# total_interest: Bankroll::Decimal["412.50"],
|
127
|
+
# balance: Bankroll::Decimal["164_716.85"]
|
128
|
+
# )
|
129
|
+
# ... and 359 more payments
|
130
|
+
# ]
|
131
|
+
|
132
|
+
```
|
133
|
+
|
134
|
+
## Development
|
135
|
+
|
136
|
+
After checking out the repo, run `bin/setup` to install dependencies.
|
137
|
+
Then, run `rake spec` to run the tests. You can also run `bin/console` for an
|
138
|
+
interactive prompt that will allow you to experiment.
|
139
|
+
|
140
|
+
To install this gem onto your local machine, run `bundle exec rake install`.
|
141
|
+
To release a new version, update the version number in `version.rb`, and then
|
142
|
+
run `bundle exec rake release`, which will create a git tag for the version,
|
143
|
+
push git commits and the created tag, and push the `.gem` file
|
144
|
+
to [rubygems.org](https://rubygems.org).
|
145
|
+
|
146
|
+
## Contributing
|
147
|
+
|
148
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/nolantait/bankroll.
|
149
|
+
|
150
|
+
## License
|
151
|
+
|
152
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/bankroll.gemspec
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/bankroll/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "bankroll"
|
7
|
+
spec.version = Bankroll::VERSION
|
8
|
+
spec.authors = ["Nolan J Tait"]
|
9
|
+
spec.email = ["nolanjtait@gmail.com"]
|
10
|
+
|
11
|
+
spec.summary = "Mortgage and refinance calculations for ruby"
|
12
|
+
spec.description = "Mortgage and refinance calculations for ruby"
|
13
|
+
spec.homepage = "https://github.com/nolantait/bankroll"
|
14
|
+
spec.license = "MIT"
|
15
|
+
spec.required_ruby_version = ">= 2.6.0"
|
16
|
+
|
17
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
18
|
+
spec.metadata["source_code_uri"] = "https://github.com/nolantait/bankroll"
|
19
|
+
spec.metadata["changelog_uri"] = "https://github.com/nolantait/bankroll/CHANGELOG.md"
|
20
|
+
|
21
|
+
# Specify which files should be added to the gem when it is released.
|
22
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
23
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
24
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
25
|
+
(f == __FILE__) || f.match(%r{\A(?:(?:test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
|
26
|
+
end
|
27
|
+
end
|
28
|
+
spec.bindir = "exe"
|
29
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
30
|
+
spec.require_paths = ["lib"]
|
31
|
+
|
32
|
+
# Uncomment to register a new dependency of your gem
|
33
|
+
spec.add_dependency "dry-types"
|
34
|
+
spec.add_dependency "dry-initializer"
|
35
|
+
spec.add_dependency "dry-equalizer"
|
36
|
+
|
37
|
+
# For more information and examples about making a new gem, checkout our
|
38
|
+
# guide at: https://bundler.io/guides/creating_gem.html
|
39
|
+
end
|
data/bin/bundle
ADDED
@@ -0,0 +1,114 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application 'bundle' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
require "rubygems"
|
12
|
+
|
13
|
+
m = Module.new do
|
14
|
+
module_function
|
15
|
+
|
16
|
+
def invoked_as_script?
|
17
|
+
File.expand_path($0) == File.expand_path(__FILE__)
|
18
|
+
end
|
19
|
+
|
20
|
+
def env_var_version
|
21
|
+
ENV["BUNDLER_VERSION"]
|
22
|
+
end
|
23
|
+
|
24
|
+
def cli_arg_version
|
25
|
+
return unless invoked_as_script? # don't want to hijack other binstubs
|
26
|
+
return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update`
|
27
|
+
bundler_version = nil
|
28
|
+
update_index = nil
|
29
|
+
ARGV.each_with_index do |a, i|
|
30
|
+
if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN
|
31
|
+
bundler_version = a
|
32
|
+
end
|
33
|
+
next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/
|
34
|
+
bundler_version = $1
|
35
|
+
update_index = i
|
36
|
+
end
|
37
|
+
bundler_version
|
38
|
+
end
|
39
|
+
|
40
|
+
def gemfile
|
41
|
+
gemfile = ENV["BUNDLE_GEMFILE"]
|
42
|
+
return gemfile if gemfile && !gemfile.empty?
|
43
|
+
|
44
|
+
File.expand_path("../../Gemfile", __FILE__)
|
45
|
+
end
|
46
|
+
|
47
|
+
def lockfile
|
48
|
+
lockfile =
|
49
|
+
case File.basename(gemfile)
|
50
|
+
when "gems.rb" then gemfile.sub(/\.rb$/, gemfile)
|
51
|
+
else "#{gemfile}.lock"
|
52
|
+
end
|
53
|
+
File.expand_path(lockfile)
|
54
|
+
end
|
55
|
+
|
56
|
+
def lockfile_version
|
57
|
+
return unless File.file?(lockfile)
|
58
|
+
lockfile_contents = File.read(lockfile)
|
59
|
+
return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/
|
60
|
+
Regexp.last_match(1)
|
61
|
+
end
|
62
|
+
|
63
|
+
def bundler_requirement
|
64
|
+
@bundler_requirement ||=
|
65
|
+
env_var_version || cli_arg_version ||
|
66
|
+
bundler_requirement_for(lockfile_version)
|
67
|
+
end
|
68
|
+
|
69
|
+
def bundler_requirement_for(version)
|
70
|
+
return "#{Gem::Requirement.default}.a" unless version
|
71
|
+
|
72
|
+
bundler_gem_version = Gem::Version.new(version)
|
73
|
+
|
74
|
+
requirement = bundler_gem_version.approximate_recommendation
|
75
|
+
|
76
|
+
return requirement unless Gem::Version.new(Gem::VERSION) < Gem::Version.new("2.7.0")
|
77
|
+
|
78
|
+
requirement += ".a" if bundler_gem_version.prerelease?
|
79
|
+
|
80
|
+
requirement
|
81
|
+
end
|
82
|
+
|
83
|
+
def load_bundler!
|
84
|
+
ENV["BUNDLE_GEMFILE"] ||= gemfile
|
85
|
+
|
86
|
+
activate_bundler
|
87
|
+
end
|
88
|
+
|
89
|
+
def activate_bundler
|
90
|
+
gem_error = activation_error_handling do
|
91
|
+
gem "bundler", bundler_requirement
|
92
|
+
end
|
93
|
+
return if gem_error.nil?
|
94
|
+
require_error = activation_error_handling do
|
95
|
+
require "bundler/version"
|
96
|
+
end
|
97
|
+
return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION))
|
98
|
+
warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`"
|
99
|
+
exit 42
|
100
|
+
end
|
101
|
+
|
102
|
+
def activation_error_handling
|
103
|
+
yield
|
104
|
+
nil
|
105
|
+
rescue StandardError, LoadError => e
|
106
|
+
e
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
m.load_bundler!
|
111
|
+
|
112
|
+
if m.invoked_as_script?
|
113
|
+
load Gem.bin_path("bundler", "bundle")
|
114
|
+
end
|
data/bin/console
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require "bankroll"
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
11
|
+
# require "pry"
|
12
|
+
# Pry.start
|
13
|
+
|
14
|
+
require "irb"
|
15
|
+
IRB.start(__FILE__)
|
data/bin/rspec
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application 'rspec' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
require "pathname"
|
12
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
|
13
|
+
Pathname.new(__FILE__).realpath)
|
14
|
+
|
15
|
+
bundle_binstub = File.expand_path("../bundle", __FILE__)
|
16
|
+
|
17
|
+
if File.file?(bundle_binstub)
|
18
|
+
if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
|
19
|
+
load(bundle_binstub)
|
20
|
+
else
|
21
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
22
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
require "rubygems"
|
27
|
+
require "bundler/setup"
|
28
|
+
|
29
|
+
load Gem.bin_path("rspec-core", "rspec")
|
data/bin/setup
ADDED
@@ -0,0 +1,69 @@
|
|
1
|
+
module Bankroll
|
2
|
+
class AmortizationSchedule
|
3
|
+
extend Dry::Initializer
|
4
|
+
extend Callable
|
5
|
+
|
6
|
+
Payment = Struct.new(
|
7
|
+
:payment,
|
8
|
+
:principal,
|
9
|
+
:interest,
|
10
|
+
:total_interest,
|
11
|
+
:balance,
|
12
|
+
keyword_init: true
|
13
|
+
)
|
14
|
+
|
15
|
+
option :present_value, Types["bankroll.decimal"]
|
16
|
+
option :interest_rate, Types["bankroll.decimal"]
|
17
|
+
option :periods, Types["integer"]
|
18
|
+
|
19
|
+
def each
|
20
|
+
Enumerator.new do |yielder|
|
21
|
+
total_interest = Decimal["0"]
|
22
|
+
balance = @present_value
|
23
|
+
|
24
|
+
periods.times do |period|
|
25
|
+
interest = interest_payment(period + 1)
|
26
|
+
principal = payment - interest
|
27
|
+
total_interest += interest
|
28
|
+
balance -= principal
|
29
|
+
|
30
|
+
yielder << Payment.new(
|
31
|
+
payment: payment.round,
|
32
|
+
principal: principal.round,
|
33
|
+
interest: interest.round,
|
34
|
+
total_interest: total_interest.round,
|
35
|
+
balance: balance.round
|
36
|
+
)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def payments
|
42
|
+
@payments ||= each.to_a
|
43
|
+
end
|
44
|
+
|
45
|
+
def call
|
46
|
+
self
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def interest_payment(period)
|
52
|
+
InterestPayment.call(
|
53
|
+
interest_rate: @interest_rate,
|
54
|
+
periods: @periods,
|
55
|
+
period: period,
|
56
|
+
present_value: @present_value,
|
57
|
+
payment: -payment
|
58
|
+
)
|
59
|
+
end
|
60
|
+
|
61
|
+
def payment
|
62
|
+
@payment ||= Bankroll::Payment.call(
|
63
|
+
present_value: @present_value,
|
64
|
+
interest_rate: @interest_rate,
|
65
|
+
periods: @periods
|
66
|
+
)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module Bankroll
|
2
|
+
class AnnuityFactor
|
3
|
+
extend Callable
|
4
|
+
extend Dry::Initializer
|
5
|
+
|
6
|
+
ONE = Decimal['1'].freeze
|
7
|
+
|
8
|
+
option :periods, Types["bankroll.decimal"]
|
9
|
+
option :interest_rate, Types["bankroll.decimal"]
|
10
|
+
|
11
|
+
def call
|
12
|
+
annuity_factor
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def annuity_factor
|
18
|
+
(ONE - interest_contribution_per_period) / @interest_rate
|
19
|
+
end
|
20
|
+
|
21
|
+
def interest_contribution_per_period
|
22
|
+
(ONE + @interest_rate) ** -@periods
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module Bankroll
|
2
|
+
class CumulativeInterest
|
3
|
+
# Calculate the total interest paid at any period
|
4
|
+
#
|
5
|
+
extend Callable
|
6
|
+
extend Dry::Initializer
|
7
|
+
|
8
|
+
option :periods, Types["bankroll.decimal"]
|
9
|
+
option :payment, Types["bankroll.decimal"]
|
10
|
+
option :interest_rate, Types["bankroll.decimal"], default: -> { ZERO }
|
11
|
+
option :present_value, Types["bankroll.decimal"]
|
12
|
+
|
13
|
+
def call
|
14
|
+
payments_made +
|
15
|
+
((total_interest - @payment) * percentage_of_interest_per_period)
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def percentage_of_interest_per_period
|
21
|
+
((compound_rate - ONE) / @interest_rate)
|
22
|
+
end
|
23
|
+
|
24
|
+
def payments_made
|
25
|
+
@payment * @periods
|
26
|
+
end
|
27
|
+
|
28
|
+
def compound_rate
|
29
|
+
(ONE + @interest_rate) ** @periods
|
30
|
+
end
|
31
|
+
|
32
|
+
def total_interest
|
33
|
+
@present_value * @interest_rate
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
module Bankroll
|
2
|
+
class Decimal < Delegator
|
3
|
+
include Dry::Equalizer.new(:value)
|
4
|
+
|
5
|
+
ROUNDING = :half_even
|
6
|
+
|
7
|
+
def self.[](value)
|
8
|
+
if value.is_a? self
|
9
|
+
value
|
10
|
+
else
|
11
|
+
new(value.to_s)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
attr_reader :value
|
16
|
+
alias_method :__getobj__, :value
|
17
|
+
|
18
|
+
def initialize(value)
|
19
|
+
decimal = case value
|
20
|
+
when self.class
|
21
|
+
value.value.to_s
|
22
|
+
else
|
23
|
+
value.to_s
|
24
|
+
end
|
25
|
+
|
26
|
+
@value = BigDecimal(Types["string"][decimal])
|
27
|
+
end
|
28
|
+
|
29
|
+
def round(precision = 2, rounding = ROUNDING)
|
30
|
+
wrap @value.round(precision, rounding)
|
31
|
+
end
|
32
|
+
|
33
|
+
def +(other)
|
34
|
+
wrap super(Decimal[other].value)
|
35
|
+
end
|
36
|
+
|
37
|
+
def -(other)
|
38
|
+
wrap super(Decimal[other].value)
|
39
|
+
end
|
40
|
+
|
41
|
+
def *(other)
|
42
|
+
wrap super(Decimal[other].value)
|
43
|
+
end
|
44
|
+
|
45
|
+
def /(other)
|
46
|
+
wrap super(Decimal[other].value)
|
47
|
+
end
|
48
|
+
|
49
|
+
def **(other)
|
50
|
+
wrap super(Decimal[other].value)
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def wrap(value)
|
56
|
+
Decimal[value]
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module Bankroll
|
2
|
+
class FutureValue
|
3
|
+
# Future value is the value to which an investment will grow
|
4
|
+
# after one or more periods. Usage is with a fixed payment
|
5
|
+
|
6
|
+
extend Callable
|
7
|
+
extend Dry::Initializer
|
8
|
+
|
9
|
+
option :periods, Types["bankroll.decimal"]
|
10
|
+
option :interest_rate, Types["bankroll.decimal"]
|
11
|
+
option :payment, Types["bankroll.decimal"], default: -> { ZERO }
|
12
|
+
option :present_value, Types["bankroll.decimal"], default: -> { ZERO }
|
13
|
+
|
14
|
+
def call
|
15
|
+
(effective_rate * present_value) +
|
16
|
+
(@payment * (1 + @interest_rate * 0) * (effective_rate - 1) / @interest_rate)
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def effective_rate
|
22
|
+
(ONE + @interest_rate) ** @periods
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module Bankroll
|
2
|
+
class InterestPayment
|
3
|
+
# The interest portion of a payment at period N
|
4
|
+
|
5
|
+
extend Callable
|
6
|
+
extend Dry::Initializer
|
7
|
+
|
8
|
+
option :interest_rate, Types["bankroll.decimal"]
|
9
|
+
option :periods, Types["bankroll.decimal"]
|
10
|
+
option :period, Types["integer"]
|
11
|
+
option :present_value, Types["bankroll.decimal"]
|
12
|
+
option :payment, Types["bankroll.decimal"] | Types["nil"], default: -> { nil }
|
13
|
+
|
14
|
+
def call
|
15
|
+
future_loan_balance * @interest_rate
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def future_loan_balance
|
21
|
+
FutureValue.call(
|
22
|
+
periods: @period - 1,
|
23
|
+
payment: payment,
|
24
|
+
present_value: @present_value,
|
25
|
+
interest_rate: @interest_rate
|
26
|
+
)
|
27
|
+
end
|
28
|
+
|
29
|
+
def payment
|
30
|
+
@payment ||= -Payment.call(
|
31
|
+
present_value: @present_value,
|
32
|
+
interest_rate: @interest_rate,
|
33
|
+
periods: @periods
|
34
|
+
)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module Bankroll
|
2
|
+
class InterestRate
|
3
|
+
extend Dry::Initializer
|
4
|
+
extend Callable
|
5
|
+
|
6
|
+
# Uses the method in the Exonio library:
|
7
|
+
# https://github.com/noverde/exonio/blob/master/lib/exonio/financial.rb
|
8
|
+
|
9
|
+
option :periods, Types["integer"]
|
10
|
+
option :payment, Types["bankroll.decimal"]
|
11
|
+
option :present_value, Types["bankroll.decimal"]
|
12
|
+
option :future_value, Types["bankroll.decimal"], default: -> { Decimal[0] }
|
13
|
+
|
14
|
+
def call(guess = 0.1)
|
15
|
+
significance_required = 1e-6
|
16
|
+
stop = false
|
17
|
+
|
18
|
+
begin
|
19
|
+
temp = newton_iteration(
|
20
|
+
guess,
|
21
|
+
@periods,
|
22
|
+
@payment,
|
23
|
+
@present_value,
|
24
|
+
@future_value,
|
25
|
+
0
|
26
|
+
)
|
27
|
+
next_guess = (guess - temp).round(20)
|
28
|
+
difference = (next_guess - guess).abs
|
29
|
+
stop = difference < significance_required
|
30
|
+
guess = next_guess
|
31
|
+
end while !stop
|
32
|
+
|
33
|
+
next_guess
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
|
39
|
+
# This method was borrowed from the NumPy rate formula
|
40
|
+
# which was generated by Sage
|
41
|
+
def newton_iteration(r, n, p, x, y, w)
|
42
|
+
t1 = (r+1)**n
|
43
|
+
t2 = (r+1)**(n-1)
|
44
|
+
((y + t1*x + p*(t1 - 1)*(r*w + 1)/r) / (n*t2*x - p*(t1 - 1)*(r*w + 1)/(r**2) + n*p*t2*(r*w + 1)/r + p*(t1 - 1)*w/r))
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|