bankroll 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
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,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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,7 @@
1
+ module Bankroll
2
+ module Callable
3
+ def call(**kwargs)
4
+ new(**kwargs).call
5
+ end
6
+ end
7
+ 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