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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/NOTICE.md +20 -0
- data/README.md +173 -85
- data/lib/finrb/accounting.rb +180 -0
- data/lib/finrb/amortization.rb +28 -23
- data/lib/finrb/cashflows.rb +101 -66
- data/lib/finrb/config.rb +53 -4
- data/lib/finrb/core_ext/array.rb +6 -0
- data/lib/finrb/core_ext/numeric.rb +12 -0
- data/lib/finrb/core_ext.rb +11 -0
- data/lib/finrb/decimal.rb +0 -10
- data/lib/finrb/errors.rb +8 -0
- data/lib/finrb/numerical/brent.rb +135 -0
- data/lib/finrb/numerical/rate_search.rb +79 -0
- data/lib/finrb/precision.rb +29 -0
- data/lib/finrb/rates.rb +46 -18
- data/lib/finrb/ratios.rb +243 -0
- data/lib/finrb/returns.rb +155 -0
- data/lib/finrb/transaction.rb +23 -7
- data/lib/finrb/tvm.rb +126 -0
- data/lib/finrb/validation.rb +27 -0
- data/lib/finrb/version.rb +6 -0
- data/lib/finrb/yields.rb +224 -0
- data/lib/finrb.rb +9 -4
- data/sig/finrb.rbs +204 -0
- metadata +50 -30
- data/lib/finrb/utils.rb +0 -1094
data/lib/finrb/amortization.rb
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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)
|
|
58
|
+
-Precision.money(principal / periods)
|
|
52
59
|
else
|
|
53
|
-
-(principal * (rate + (rate / (((rate + 1)**periods) - 1))))
|
|
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 =
|
|
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 =
|
|
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)
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
data/lib/finrb/cashflows.rb
CHANGED
|
@@ -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 '
|
|
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
|
-
|
|
17
|
+
class << self
|
|
18
|
+
def irr(cashflows, guess = nil)
|
|
19
|
+
sequence(cashflows).irr(guess)
|
|
20
|
+
end
|
|
16
21
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
BigDecimal(value)
|
|
25
|
-
end
|
|
26
|
+
def xirr(transactions, guess = nil)
|
|
27
|
+
sequence(transactions).xirr(guess)
|
|
26
28
|
end
|
|
27
29
|
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
-
@function = function
|
|
30
|
+
def xnpv(transactions, rate)
|
|
31
|
+
sequence(transactions).xnpv(rate)
|
|
31
32
|
end
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
rate
|
|
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
|
-
#
|
|
129
|
+
# Finrb::Cashflow.xnpv(@transactions, 0.6).round(2) #=> -937.41
|
|
119
130
|
# @api public
|
|
120
131
|
def xnpv(rate)
|
|
121
|
-
|
|
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.
|
|
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
|
|
171
|
+
business_days_between(start.to_date, stop).to_f
|
|
141
172
|
else
|
|
142
|
-
Flt::DecNum.new(365
|
|
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.
|
|
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
|
-
|
|
5
|
-
|
|
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 ||=
|
|
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
|
-
|
|
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,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
|
data/lib/finrb/decimal.rb
CHANGED
data/lib/finrb/errors.rb
ADDED
|
@@ -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
|