finrb 0.1.11 → 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.
@@ -2,23 +2,22 @@
2
2
 
3
3
  require_relative 'cashflows'
4
4
  require_relative 'decimal'
5
+ require_relative 'precision'
5
6
  require_relative 'transaction'
7
+ require_relative 'validation'
6
8
 
7
9
  module Finrb
8
10
  # the Amortization class provides an interface for working with loan amortizations.
9
- # @note There are _two_ ways to create an amortization. The first
10
- # example uses the amortize method for the Numeric class. The second
11
- # calls Amortization.new directly.
12
11
  # @example Borrow $250,000 under a 30 year, fixed-rate loan with a 4.25% APR
13
12
  # rate = Rate.new(0.0425, :apr, :duration => (30 * 12))
14
- # amortization = 250000.amortize(rate)
13
+ # amortization = Finrb::Amortization.new(250000, rate)
15
14
  # @example Borrow $250,000 under a 30 year, adjustable rate loan, with an APR starting at 4.25%, and increasing by 1% every five years
16
15
  # values = %w{ 0.0425 0.0525 0.0625 0.0725 0.0825 0.0925 }
17
16
  # rates = values.collect { |value| Rate.new( value, :apr, :duration = (5 * 12) ) }
18
17
  # arm = Amortization.new(250000, *rates)
19
18
  # @example Borrow $250,000 under a 30 year, fixed-rate loan with a 4.25% APR, but pay $150 extra each month
20
19
  # rate = Rate.new(0.0425, :apr, :duration => (5 * 12))
21
- # extra_payments = 250000.amortize(rate){ |period| period.payment - 150 }
20
+ # extra_payments = Finrb::Amortization.new(250000, rate){ |period| period.payment - 150 }
22
21
  # @api public
23
22
  class Amortization
24
23
  # @return [Flt::DecNum] the balance of the loan at the end of the amortization period (usually zero)
@@ -46,11 +45,19 @@ module Finrb
46
45
  # @see https://en.wikipedia.org/wiki/Amortization_calculator
47
46
  # @api public
48
47
  def self.payment(principal, rate, periods)
48
+ principal = Validation.decimal(principal, name: 'principal')
49
+ raise(ArgumentError, 'principal must be positive.') unless principal.positive?
50
+
51
+ rate = Validation.decimal(rate, name: 'rate')
52
+ raise(ArgumentError, 'periodic rate must be greater than -1.') if rate <= -1
53
+
54
+ periods = Validation.positive_integer(periods, name: 'periods')
55
+
49
56
  if rate.zero?
50
57
  # simplified formula to avoid division-by-zero when interest rate is zero
51
- -(principal / periods).round(2)
58
+ -Precision.money(principal / periods)
52
59
  else
53
- -(principal * (rate + (rate / (((rate + 1)**periods) - 1)))).round(2)
60
+ -Precision.money(principal * (rate + (rate / (((rate + 1)**periods) - 1))))
54
61
  end
55
62
  end
56
63
 
@@ -61,7 +68,12 @@ module Finrb
61
68
  # @param [Proc] block
62
69
  # @api public
63
70
  def initialize(principal, *rates, &block)
64
- @principal = Flt::DecNum.new(principal.to_s)
71
+ @principal = Validation.decimal(principal, name: 'principal')
72
+ raise(ArgumentError, 'principal must be positive.') unless @principal.positive?
73
+ raise(ArgumentError, 'at least one rate is required.') if rates.empty?
74
+ raise(ArgumentError, 'rates must be Finrb::Rate instances.') unless rates.all?(Rate)
75
+ raise(ArgumentError, 'every rate must have a duration.') if rates.any? { |rate| rate.duration.nil? }
76
+
65
77
  @rates = rates
66
78
  @block = block
67
79
 
@@ -83,7 +95,7 @@ module Finrb
83
95
  # @return [Array] the amount of any additional payments in each period
84
96
  # @example
85
97
  # rate = Rate.new(0.0375, :apr, :duration => (30 * 12))
86
- # amt = 300000.amortize(rate){ |payment| payment.amount-100}
98
+ # amt = Finrb::Amortization.new(300000, rate){ |payment| payment.amount-100}
87
99
  # amt.additional_payments #=> [Flt::DecNum('-100.00'), Flt::DecNum('-100.00'), ... ]
88
100
  # @api public
89
101
  def additional_payments
@@ -103,13 +115,14 @@ module Finrb
103
115
 
104
116
  pmt = Payment.new(amount, period: @period)
105
117
  pmt.modify(&@block) if @block
118
+ raise(ArgumentError, 'payment modification must produce a negative amount.') unless pmt.amount.negative?
106
119
 
107
120
  rate.duration.to_i.times do
108
121
  # Do this first in case the balance is zero already.
109
122
  break if @balance.zero?
110
123
 
111
124
  # Compute and record interest on the outstanding balance.
112
- int = (@balance * rate.monthly).round(2)
125
+ int = Precision.money(@balance * rate.monthly)
113
126
  interest = Interest.new(int, period: @period)
114
127
  @balance += interest.amount
115
128
  @transactions << interest.dup
@@ -148,11 +161,11 @@ module Finrb
148
161
  # @return [Integer] the time required to pay off the loan, in months
149
162
  # @example In most cases, the duration is equal to the total duration of all rates
150
163
  # rate = Rate.new(0.0375, :apr, :duration => (30 * 12))
151
- # amt = 300000.amortize(rate)
164
+ # amt = Finrb::Amortization.new(300000, rate)
152
165
  # amt.duration #=> 360
153
166
  # @example Extra payments may reduce the duration
154
167
  # rate = Rate.new(0.0375, :apr, :duration => (30 * 12))
155
- # amt = 300000.amortize(rate){ |payment| payment.amount-100}
168
+ # amt = Finrb::Amortization.new(300000, rate){ |payment| payment.amount-100}
156
169
  # amt.duration #=> 319
157
170
  # @api public
158
171
  def duration
@@ -167,11 +180,11 @@ module Finrb
167
180
  # @return [Array] the amount of interest charged in each period
168
181
  # @example find the total cost of interest for a loan
169
182
  # rate = Rate.new(0.0375, :apr, :duration => (30 * 12))
170
- # amt = 300000.amortize(rate)
183
+ # amt = Finrb::Amortization.new(300000, rate)
171
184
  # amt.interest.sum #=> Flt::DecNum('200163.94')
172
185
  # @example find the total interest charges in the first six months
173
186
  # rate = Rate.new(0.0375, :apr, :duration => (30 * 12))
174
- # amt = 300000.amortize(rate)
187
+ # amt = Finrb::Amortization.new(300000, rate)
175
188
  # amt.interest[0,6].sum #=> Flt::DecNum('5603.74')
176
189
  # @api public
177
190
  def interest
@@ -181,7 +194,7 @@ module Finrb
181
194
  # @return [Array] the amount of the payment in each period
182
195
  # @example find the total payments for a loan
183
196
  # rate = Rate.new(0.0375, :apr, :duration => (30 * 12))
184
- # amt = 300000.amortize(rate)
197
+ # amt = Finrb::Amortization.new(300000, rate)
185
198
  # amt.payments.sum #=> Flt::DecNum('-500163.94')
186
199
  # @api public
187
200
  def payments
@@ -189,11 +202,3 @@ module Finrb
189
202
  end
190
203
  end
191
204
  end
192
-
193
- class Numeric
194
- # @see Amortization#new
195
- # @api public
196
- def amortize(...)
197
- Finrb::Amortization.new(self, ...)
198
- end
199
- end
@@ -2,83 +2,83 @@
2
2
 
3
3
  require_relative 'config'
4
4
  require_relative 'decimal'
5
+ require_relative 'errors'
6
+ require_relative 'numerical/brent'
7
+ require_relative 'numerical/rate_search'
5
8
  require_relative 'rates'
9
+ require_relative 'validation'
6
10
 
7
- require 'bigdecimal'
8
- require 'bigdecimal/newton'
9
- require 'business_time'
11
+ require 'date'
10
12
 
11
13
  module Finrb
12
14
  # Provides methods for working with cash flows (collections of transactions)
13
15
  # @api public
14
16
  module Cashflow
15
- include Newton
17
+ class << self
18
+ def irr(cashflows, guess = nil)
19
+ sequence(cashflows).irr(guess)
20
+ end
16
21
 
17
- # Base class for working with Newton's Method.
18
- # @api private
19
- class Function
20
- values = { eps: Finrb.config.eps, one: '1.0', two: '2.0', ten: '10.0', zero: '0.0' }
22
+ def npv(cashflows, rate)
23
+ sequence(cashflows).npv(rate)
24
+ end
21
25
 
22
- values.each do |key, value|
23
- define_method key do
24
- BigDecimal(value)
25
- end
26
+ def xirr(transactions, guess = nil)
27
+ sequence(transactions).xirr(guess)
26
28
  end
27
29
 
28
- def initialize(transactions, function)
29
- @transactions = transactions
30
- @function = function
30
+ def xnpv(transactions, rate)
31
+ sequence(transactions).xnpv(rate)
31
32
  end
32
33
 
33
- def values(x)
34
- value = @transactions.public_send(@function, Flt::DecNum.new(x.first.to_s))
35
- begin
36
- [BigDecimal(value.to_s)]
37
- rescue ArgumentError
38
- [0]
39
- end
34
+ private
35
+
36
+ def sequence(cashflows)
37
+ raise(ArgumentError, 'cashflows must be an enumerable collection') unless cashflows.respond_to?(:to_a)
38
+
39
+ cashflows.to_a.extend(Finrb::Cashflow)
40
40
  end
41
41
  end
42
42
 
43
- # calculate the internal rate of return for a sequence of cash flows
44
- # @return [Flt::DecNum] the internal rate of return
45
- # @param [Numeric] guess Initial guess rate, Defaults to 1.0
43
+ # Calculate the per-period internal rate of return for an ordered sequence
44
+ # of equally spaced cashflows.
45
+ #
46
+ # For cashflows with multiple sign-changing roots, the guess determines
47
+ # which nearby root is selected. Rates must be greater than -1.
48
+ # @return [Flt::DecNum] the per-period internal rate of return
49
+ # @param [Numeric, nil] guess initial rate used for root selection; defaults
50
+ # to +Finrb.config.guess+
51
+ # @raise [InvalidCashflowError] if the sequence lacks both cashflow signs
52
+ # @raise [ArgumentError] if the guess is not numeric
53
+ # @raise [DomainError] if the rate domain or function evaluation is invalid
54
+ # @raise [ConvergenceError] if no root can be bracketed or solved
46
55
  # @example
47
- # [-4000,1200,1410,1875,1050].irr #=> 0.143
56
+ # Finrb::Cashflow.irr([-4000,1200,1410,1875,1050]) #=> 0.143
48
57
  # @see https://en.wikipedia.org/wiki/Internal_rate_of_return
49
58
  # @api public
50
59
  def irr(guess = nil)
51
- # Make sure we have a valid sequence of cash flows.
52
- positives, negatives = partition { |i| i >= 0 }
53
- raise(ArgumentError, 'Calculation does not converge.') if positives.empty? || negatives.empty?
54
-
55
- func = Function.new(self, :npv)
56
- rate = [valid(guess)]
57
- nlsolve(func, rate)
58
- rate.first
59
- end
60
-
61
- def method_missing(name, *args, &)
62
- return sum if name.to_s == 'sum'
60
+ validate_numeric_cashflows!
63
61
 
64
- super
65
- end
62
+ # Make sure we have a valid sequence of cash flows.
63
+ raise(InvalidCashflowError, 'Cashflow needs at least one positive and one negative value.') if none?(&:positive?) || none?(&:negative?)
66
64
 
67
- def respond_to_missing?(name, include_private = false)
68
- name.to_s == 'sum' || super
65
+ solve(:npv, valid(guess))
69
66
  end
70
67
 
71
68
  # calculate the net present value of a sequence of cash flows
72
69
  # @return [Flt::DecNum] the net present value
73
70
  # @param [Numeric] rate the discount rate to be applied
74
71
  # @example
75
- # [-100.0, 60, 60, 60].npv(0.1) #=> 49.211
72
+ # Finrb::Cashflow.npv([-100.0, 60, 60, 60], 0.1) #=> 49.211
76
73
  # @see https://en.wikipedia.org/wiki/Net_present_value
77
74
  # @api public
78
75
  def npv(rate)
79
- cashflows = map { |entry| Flt::DecNum.new(entry.to_s) }
76
+ validate_numeric_cashflows!
77
+ cashflows = map { |entry| Validation.decimal(entry, name: 'cashflow amount') }
78
+
79
+ rate = Validation.decimal(rate, name: 'rate')
80
+ raise(DomainError, 'Rate must be greater than -1.') if rate <= -1
80
81
 
81
- rate = Flt::DecNum.new(rate.to_s)
82
82
  total = Flt::DecNum.new(0.to_s)
83
83
  cashflows.each_with_index do |cashflow, index|
84
84
  total += cashflow / ((rate + 1)**index)
@@ -87,25 +87,36 @@ module Finrb
87
87
  total
88
88
  end
89
89
 
90
- # calculate the internal rate of return for a sequence of cash flows with dates
91
- # @param[Numeric] Initial guess rate, Deafults to 1.0
92
- # @return [Rate] the internal rate of return
90
+ # Calculate the effective annual internal rate of return for an ordered
91
+ # sequence of dated transactions.
92
+ #
93
+ # Under the default configuration, date offsets are actual calendar days
94
+ # from the first transaction and a 365-day year is used. Transactions
95
+ # should be supplied chronologically and their dates must respond to
96
+ # +to_date+. For multiple roots, the guess determines which nearby root is
97
+ # selected. Rates must be greater than -1.
98
+ # @param [Numeric, nil] guess initial rate used for root selection; defaults
99
+ # to +Finrb.config.guess+
100
+ # @return [Rate] the effective annual internal rate of return
101
+ # @raise [InvalidCashflowError] if the sequence lacks both cashflow signs
102
+ # @raise [ArgumentError] if the guess is not numeric
103
+ # @raise [DomainError] if the rate domain or function evaluation is invalid
104
+ # @raise [ConvergenceError] if no root can be bracketed or solved
93
105
  # @example
94
106
  # @transactions = []
95
107
  # @transactions << Transaction.new(-1000, :date => Time.new(1985,01,01))
96
108
  # @transactions << Transaction.new( 600, :date => Time.new(1990,01,01))
97
109
  # @transactions << Transaction.new( 600, :date => Time.new(1995,01,01))
98
- # @transactions.xirr(0.6) #=> Rate("0.024851", :apr, :compounds => :annually)
110
+ # Finrb::Cashflow.xirr(@transactions, 0.6) #=> Rate("0.024851", :effective, :compounds => :annually)
99
111
  # @api public
100
112
  def xirr(guess = nil)
113
+ validate_dated_cashflows!
114
+
101
115
  # Make sure we have a valid sequence of cash flows.
102
- positives, negatives = partition { |t| t.amount >= 0 }
103
- raise(ArgumentError, 'Calculation does not converge. Cashflow needs to have a least one positive and one negative value.') if positives.empty? || negatives.empty?
116
+ raise(InvalidCashflowError, 'Cashflow needs at least one positive and one negative value.') if none? { |transaction| transaction.amount.positive? } || none? { |transaction| transaction.amount.negative? }
104
117
 
105
- func = Function.new(self, :xnpv)
106
- rate = [valid(guess)]
107
- nlsolve(func, rate)
108
- Rate.new(rate.first, :apr, compounds: Finrb.config.periodic_compound ? :continuously : :annually)
118
+ rate = solve(:xnpv, valid(guess))
119
+ Rate.new(rate, :effective, compounds: Finrb.config.periodic_compound ? :continuously : :annually)
109
120
  end
110
121
 
111
122
  # calculate the net present value of a sequence of cash flows
@@ -115,10 +126,12 @@ module Finrb
115
126
  # @transactions << Transaction.new(-1000, :date => Time.new(1985,01,01))
116
127
  # @transactions << Transaction.new( 600, :date => Time.new(1990,01,01))
117
128
  # @transactions << Transaction.new( 600, :date => Time.new(1995,01,01))
118
- # @transactions.xnpv(0.6).round(2) #=> -937.41
129
+ # Finrb::Cashflow.xnpv(@transactions, 0.6).round(2) #=> -937.41
119
130
  # @api public
120
131
  def xnpv(rate)
121
- rate = Flt::DecNum.new(rate.to_s)
132
+ validate_dated_cashflows!
133
+ rate = Validation.decimal(rate, name: 'rate')
134
+ raise(DomainError, 'Rate must be greater than -1.') if rate <= -1
122
135
 
123
136
  sum do |t|
124
137
  t.amount / ((rate + 1)**(date_diff(start, t.date) / days_in_period))
@@ -127,19 +140,37 @@ module Finrb
127
140
 
128
141
  private
129
142
 
143
+ def validate_numeric_cashflows!
144
+ raise(InvalidCashflowError, 'Cashflow cannot be empty.') if empty?
145
+
146
+ each { |amount| Validation.decimal(amount, name: 'cashflow amount') }
147
+ rescue ArgumentError => e
148
+ raise(InvalidCashflowError, e.message, e.backtrace)
149
+ end
150
+
151
+ def validate_dated_cashflows!
152
+ raise(InvalidCashflowError, 'Dated cashflow cannot be empty.') if empty?
153
+ raise(InvalidCashflowError, 'Dated cashflows require Finrb::Transaction instances with dates.') unless all? { |transaction| transaction.is_a?(Transaction) && transaction.date.respond_to?(:to_date) }
154
+ raise(InvalidCashflowError, 'Dated cashflows must be in chronological order.') unless each_cons(2).all? { |left, right| left.date.to_date <= right.date.to_date }
155
+ end
156
+
130
157
  def date_diff(from, to)
131
158
  if Finrb.config.business_days
132
- from.to_date.business_days_until(to)
159
+ business_days_between(from.to_date, to.to_date)
133
160
  else
134
- to - from
161
+ to.to_date - from.to_date
135
162
  end
136
163
  end
137
164
 
165
+ def business_days_between(from, to)
166
+ (from...to).count { |date| (1..5).cover?(date.wday) }
167
+ end
168
+
138
169
  def days_in_period
139
170
  if Finrb.config.periodic_compound && Finrb.config.business_days
140
- start.to_date.business_days_until(stop).to_f
171
+ business_days_between(start.to_date, stop).to_f
141
172
  else
142
- Flt::DecNum.new(365.days.to_s)
173
+ Flt::DecNum.new(365)
143
174
  end
144
175
  end
145
176
 
@@ -147,6 +178,14 @@ module Finrb
147
178
  @start ||= first.date
148
179
  end
149
180
 
181
+ def solve(function, guess)
182
+ rate_function = ->(rate) { public_send(function, rate) }
183
+ bounds = Numerical::RateSearch.new.bracket(rate_function, guess:)
184
+ return bounds.first if bounds.first == bounds.last
185
+
186
+ Numerical::Brent.new(tolerance: Finrb.config.eps).solve(rate_function, lower: bounds.first, upper: bounds.last)
187
+ end
188
+
150
189
  def stop
151
190
  @stop ||= last.date.to_date
152
191
  end
@@ -160,11 +199,7 @@ module Finrb
160
199
  raise(ArgumentError, 'Invalid Guess. Use a [Numeric] value.') unless guess.is_a?(Numeric)
161
200
 
162
201
  guess
163
- end.to_f
202
+ end.then { |value| Flt::DecNum.new(value.to_s) }
164
203
  end
165
204
  end
166
205
  end
167
-
168
- class Array
169
- include Finrb::Cashflow
170
- end
data/lib/finrb/config.rb CHANGED
@@ -1,14 +1,63 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'validation'
4
+
3
5
  module Finrb
4
- Config = Struct.new(:eps, :guess, :business_days, :periodic_compound)
5
- private_constant :Config
6
+ Configuration = Data.define(:eps, :guess, :business_days, :periodic_compound)
7
+ ConfigurationBuilder = Struct.new(:eps, :guess, :business_days, :periodic_compound)
8
+ DEFAULT_CONFIGURATION = Configuration.new(eps: Flt::DecNum.new('1.0e-16'), guess: Flt::DecNum.new('1.0'), business_days: false, periodic_compound: false)
9
+ CONFIG_OVERRIDE_KEY = :finrb_configuration_override
10
+ private_constant :Configuration, :ConfigurationBuilder, :DEFAULT_CONFIGURATION, :CONFIG_OVERRIDE_KEY
6
11
 
7
12
  def self.config
8
- @config ||= Config.new(eps: '1.0e-16', guess: 1.0, business_days: false, periodic_compound: false)
13
+ Thread.current[CONFIG_OVERRIDE_KEY] || (@config ||= DEFAULT_CONFIGURATION)
9
14
  end
10
15
 
16
+ # Atomically replace the process-wide defaults. Configure the application at
17
+ # startup; use +with_config+ for temporary or concurrent overrides.
11
18
  def self.configure
12
- yield(config)
19
+ raise(ArgumentError, 'configuration requires a block.') unless block_given?
20
+
21
+ builder = ConfigurationBuilder.new(**config.to_h)
22
+ yield(builder)
23
+ @config = build_configuration(builder.to_h)
24
+ end
25
+
26
+ # Apply a validated configuration only for the current execution context and
27
+ # restore the previous configuration even when the block raises.
28
+ def self.with_config(**overrides)
29
+ raise(ArgumentError, 'configuration override requires a block.') unless block_given?
30
+
31
+ unknown = overrides.keys - config.to_h.keys
32
+ raise(ArgumentError, "unknown configuration options: #{unknown.join(', ')}") unless unknown.empty?
33
+
34
+ previous = Thread.current[CONFIG_OVERRIDE_KEY]
35
+ Thread.current[CONFIG_OVERRIDE_KEY] = build_configuration(config.to_h.merge(overrides))
36
+ begin
37
+ yield
38
+ ensure
39
+ Thread.current[CONFIG_OVERRIDE_KEY] = previous
40
+ end
41
+ end
42
+
43
+ def self.build_configuration(values)
44
+ eps = configuration_decimal(values.fetch(:eps), name: 'eps')
45
+ raise(ArgumentError, 'eps must be positive.') unless eps.positive?
46
+
47
+ guess = configuration_decimal(values.fetch(:guess), name: 'guess')
48
+ raise(ArgumentError, 'guess must be greater than -1.') if guess <= -1
49
+
50
+ business_days = values.fetch(:business_days)
51
+ periodic_compound = values.fetch(:periodic_compound)
52
+ booleans = [business_days, periodic_compound].all? { |value| value.equal?(true) || value.equal?(false) }
53
+ raise(ArgumentError, 'business_days and periodic_compound must be boolean.') unless booleans
54
+
55
+ Configuration.new(eps:, guess:, business_days:, periodic_compound:)
56
+ end
57
+
58
+ def self.configuration_decimal(value, name:)
59
+ value = Flt::DecNum.new(value) if value.is_a?(String)
60
+ Validation.decimal(value, name:)
13
61
  end
62
+ private_class_method :build_configuration, :configuration_decimal
14
63
  end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Legacy fluent cashflow API, loaded only through +finrb/core_ext+.
4
+ class Array
5
+ include Finrb::Cashflow
6
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Legacy numeric convenience API, loaded only through +finrb/core_ext+.
4
+ class Numeric
5
+ def to_dec
6
+ instance_of?(Flt::DecNum) ? self : Flt::DecNum(to_s)
7
+ end
8
+
9
+ def amortize(...)
10
+ Finrb::Amortization.new(self, ...)
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'finrb'
4
+ require 'finrb/core_ext/array'
5
+ require 'finrb/core_ext/numeric'
6
+
7
+ module Finrb
8
+ # Namespace marker for the explicitly loaded legacy core extensions.
9
+ module CoreExt
10
+ end
11
+ end
data/lib/finrb/decimal.rb CHANGED
@@ -10,13 +10,3 @@ end
10
10
  Flt::DecNum.context.define_conversion_to(BigDecimal) do |x|
11
11
  BigDecimal(x.to_s)
12
12
  end
13
-
14
- class Numeric
15
- def to_dec
16
- if instance_of?(Flt::DecNum)
17
- self
18
- else
19
- Flt::DecNum(to_s)
20
- end
21
- end
22
- end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Finrb
4
+ class Error < StandardError; end
5
+ class ConvergenceError < Error; end
6
+ class DomainError < Error; end
7
+ class InvalidCashflowError < Error; end
8
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'flt'
4
+ require_relative '../errors'
5
+
6
+ module Finrb
7
+ module Numerical
8
+ # Brent-Dekker root solver for a continuous function on a sign-changing
9
+ # interval. The interpolation steps are safeguarded by bisection.
10
+ #
11
+ # Algorithm: R. P. Brent, Algorithms for Minimization Without Derivatives,
12
+ # Chapter 4 (1973). See also the GNU GSL root-finding documentation:
13
+ # https://www.gnu.org/software/gsl/doc/html/roots.html
14
+ class Brent
15
+ DEFAULT_MAX_ITERATIONS = 256
16
+ private_constant :DEFAULT_MAX_ITERATIONS
17
+
18
+ def initialize(tolerance:, relative_tolerance: tolerance, max_iterations: DEFAULT_MAX_ITERATIONS)
19
+ @absolute_tolerance = decimal(tolerance)
20
+ @relative_tolerance = decimal(relative_tolerance)
21
+ @max_iterations = Integer(max_iterations)
22
+
23
+ raise(ArgumentError, 'Tolerance must be positive.') unless @absolute_tolerance.positive? && @relative_tolerance.positive?
24
+ raise(ArgumentError, 'Maximum iterations must be positive.') unless @max_iterations.positive?
25
+ end
26
+
27
+ def solve(function, lower:, upper:)
28
+ left = decimal(lower)
29
+ right = decimal(upper)
30
+ raise(ArgumentError, 'Lower bound must be less than upper bound.') if left >= right
31
+
32
+ left_value = evaluate(function, left)
33
+ right_value = evaluate(function, right)
34
+
35
+ return left if left_value.zero?
36
+ return right if right_value.zero?
37
+ raise(ConvergenceError, 'Root is not bracketed.') unless opposite_signs?(left_value, right_value)
38
+
39
+ left, right, left_value, right_value = best_approximation_last(left, right, left_value, right_value)
40
+
41
+ previous = left
42
+ previous_value = left_value
43
+ penultimate = previous
44
+ bisected = true
45
+
46
+ @max_iterations.times do
47
+ tolerance = @absolute_tolerance + (@relative_tolerance * right.abs)
48
+ return right if right_value.zero? || (right - left).abs <= tolerance
49
+
50
+ candidate =
51
+ if distinct_values?(left_value, right_value, previous_value)
52
+ inverse_quadratic(left, right, previous, left_value, right_value, previous_value)
53
+ else
54
+ right - (right_value * (right - left) / (right_value - left_value))
55
+ end
56
+
57
+ bound = ((left * 3) + right) / 4
58
+ outside_safe_interval = candidate <= [bound, right].min || candidate >= [bound, right].max
59
+ insufficient_progress =
60
+ (candidate - right).abs >= if bisected
61
+ ((right - previous).abs / 2)
62
+ else
63
+ ((previous - penultimate).abs / 2)
64
+ end
65
+ bracket_too_small =
66
+ if bisected
67
+ (right - previous).abs < tolerance
68
+ else
69
+ (previous - penultimate).abs < tolerance
70
+ end
71
+
72
+ if outside_safe_interval || insufficient_progress || bracket_too_small
73
+ candidate = (left + right) / 2
74
+ bisected = true
75
+ else
76
+ bisected = false
77
+ end
78
+
79
+ candidate_value = evaluate(function, candidate)
80
+ penultimate = previous
81
+ previous = right
82
+ previous_value = right_value
83
+
84
+ if opposite_signs?(left_value, candidate_value)
85
+ right = candidate
86
+ right_value = candidate_value
87
+ else
88
+ left = candidate
89
+ left_value = candidate_value
90
+ end
91
+
92
+ left, right, left_value, right_value = best_approximation_last(left, right, left_value, right_value)
93
+ end
94
+
95
+ raise(ConvergenceError, "Calculation did not converge after #{@max_iterations} iterations.")
96
+ end
97
+
98
+ private
99
+
100
+ def inverse_quadratic(left, right, previous, left_value, right_value, previous_value)
101
+ left_term = (left * right_value * previous_value) / ((left_value - right_value) * (left_value - previous_value))
102
+ right_term = (right * left_value * previous_value) / ((right_value - left_value) * (right_value - previous_value))
103
+ previous_term = (previous * left_value * right_value) / ((previous_value - left_value) * (previous_value - right_value))
104
+ left_term + right_term + previous_term
105
+ end
106
+
107
+ def best_approximation_last(left, right, left_value, right_value)
108
+ return [right, left, right_value, left_value] if left_value.abs < right_value.abs
109
+
110
+ [left, right, left_value, right_value]
111
+ end
112
+
113
+ def distinct_values?(*values)
114
+ values.uniq.length == values.length
115
+ end
116
+
117
+ def evaluate(function, value)
118
+ result = decimal(function.call(value))
119
+ raise(DomainError, "Solver function returned a non-finite value at #{value}.") unless result.finite?
120
+
121
+ result
122
+ rescue Flt::Num::Exception, FloatDomainError, Math::DomainError, ZeroDivisionError => e
123
+ raise(DomainError, "Solver function is undefined at #{value}: #{e.message}", e.backtrace)
124
+ end
125
+
126
+ def decimal(value)
127
+ Flt::DecNum.new(value.to_s)
128
+ end
129
+
130
+ def opposite_signs?(left, right)
131
+ left.negative? != right.negative?
132
+ end
133
+ end
134
+ end
135
+ end