bankroll 0.1.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 +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
|