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.
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