finrb 1.1.0 → 1.3.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/lib/finrb/returns.rb CHANGED
@@ -29,12 +29,9 @@ module Finrb
29
29
  # @param periods [Integer] number of equal annual periods
30
30
  # @return [Flt::DecNum] compound growth rate per period
31
31
  def self.cagr(beginning_value:, ending_value:, periods:)
32
- beginning_value = Validation.decimal(beginning_value, name: 'beginning_value')
33
- ending_value = Validation.decimal(ending_value, name: 'ending_value')
34
- periods = Validation.positive_integer(periods, name: 'periods')
35
-
36
- raise(ArgumentError, 'beginning_value must be greater than zero.') unless beginning_value.positive?
37
- raise(ArgumentError, 'ending_value must be greater than or equal to zero.') if ending_value.negative?
32
+ beginning_value = Validation.positive_decimal(beginning_value, name: 'beginning value')
33
+ ending_value = Validation.non_negative_decimal(ending_value, name: 'ending value')
34
+ periods = Validation.positive_integer(periods, name: 'period count')
38
35
 
39
36
  ((ending_value / beginning_value)**(Flt::DecNum(1) / periods)) - 1
40
37
  end
@@ -49,18 +46,16 @@ module Finrb
49
46
 
50
47
  # Compound a periodic return into an annual return.
51
48
  def self.annualize_return(rate:, periods_per_year:)
52
- rate = Validation.decimal(rate, name: 'rate')
53
- periods_per_year = Validation.positive_integer(periods_per_year, name: 'periods_per_year')
54
- raise(ArgumentError, 'rate must be greater than or equal to -1.') if rate < -1
49
+ rate = Validation.decimal_at_least(rate, minimum: -1, name: 'periodic rate')
50
+ periods_per_year = Validation.positive_integer(periods_per_year, name: 'periods per year')
55
51
 
56
52
  ((rate + 1)**periods_per_year) - 1
57
53
  end
58
54
 
59
55
  # Scale periodic volatility by the square root of periods per year.
60
56
  def self.annualize_volatility(volatility:, periods_per_year:)
61
- volatility = Validation.decimal(volatility, name: 'volatility')
62
- periods_per_year = Validation.positive_integer(periods_per_year, name: 'periods_per_year')
63
- raise(ArgumentError, 'volatility must be greater than or equal to zero.') if volatility.negative?
57
+ volatility = Validation.non_negative_decimal(volatility, name: 'volatility')
58
+ periods_per_year = Validation.positive_integer(periods_per_year, name: 'periods per year')
64
59
 
65
60
  volatility * (Flt::DecNum(periods_per_year)**Flt::DecNum('0.5'))
66
61
  end
@@ -103,7 +98,7 @@ module Finrb
103
98
  ratio = ((returns.sum / returns.size) - target) / downside
104
99
  return ratio if periods_per_year.nil?
105
100
 
106
- periods_per_year = Validation.positive_integer(periods_per_year, name: 'periods_per_year')
101
+ periods_per_year = Validation.positive_integer(periods_per_year, name: 'periods per year')
107
102
  ratio * (Flt::DecNum(periods_per_year)**Flt::DecNum('0.5'))
108
103
  end
109
104
 
@@ -126,8 +121,9 @@ module Finrb
126
121
  # @example
127
122
  # Finrb::Returns.coefficient_variation(sd=0.15,avg=0.39)
128
123
  def self.coefficient_variation(sd:, avg:)
129
- sd = Flt::DecNum(sd.to_s)
130
- avg = Flt::DecNum(avg.to_s)
124
+ sd = Validation.non_negative_decimal(sd, name: 'standard deviation')
125
+ avg = Validation.decimal(avg, name: 'average')
126
+ raise(DomainError, 'Average must be non-zero.') if avg.zero?
131
127
 
132
128
  (sd / avg)
133
129
  end
@@ -138,10 +134,13 @@ module Finrb
138
134
  # @example
139
135
  # Finrb::Returns.geometric_mean(r=[-0.0934, 0.2345, 0.0892])
140
136
  def self.geometric_mean(r:)
141
- r = wrap_array(r).map { |value| Flt::DecNum(value.to_s) }
137
+ returns = risk_values(r, name: 'return')
138
+ returns.each do |value|
139
+ raise(DomainError, 'Returns must be greater than or equal to -1.') if value < -1
140
+ end
142
141
 
143
- rs = r.map { |value| value + 1 }
144
- ((rs.reduce(:*)**(Flt::DecNum(1) / rs.size)) - 1)
142
+ growth_factors = returns.map { |value| value + 1 }
143
+ ((growth_factors.reduce(:*)**(Flt::DecNum(1) / growth_factors.size)) - 1)
145
144
  end
146
145
 
147
146
  # harmonic mean, average price
@@ -149,9 +148,10 @@ module Finrb
149
148
  # @example
150
149
  # Finrb::Returns.harmonic_mean(p=[8,9,10])
151
150
  def self.harmonic_mean(p:)
152
- p = wrap_array(p).map { |value| Flt::DecNum(value.to_s) }
151
+ prices = risk_values(p, name: 'price')
152
+ raise(DomainError, 'Prices must be greater than zero.') unless prices.all?(&:positive?)
153
153
 
154
- (Flt::DecNum(1) / (p.sum { |val| Flt::DecNum(1) / val } / p.size))
154
+ (Flt::DecNum(1) / (prices.sum { |price| Flt::DecNum(1) / price } / prices.size))
155
155
  end
156
156
 
157
157
  # Computing HPR, the holding period return
@@ -162,9 +162,9 @@ module Finrb
162
162
  # @example
163
163
  # Finrb::Returns.hpr(ev=33,bv=30,cfr=0.5)
164
164
  def self.hpr(ev:, bv:, cfr: 0)
165
- ev = Flt::DecNum(ev.to_s)
166
- bv = Flt::DecNum(bv.to_s)
167
- cfr = Flt::DecNum(cfr.to_s)
165
+ ev = Validation.decimal(ev, name: 'ending value')
166
+ bv = Validation.positive_decimal(bv, name: 'beginning value', error: DomainError)
167
+ cfr = Validation.decimal(cfr, name: 'cashflow received')
168
168
 
169
169
  ((ev - bv + cfr) / bv)
170
170
  end
@@ -176,8 +176,8 @@ module Finrb
176
176
  # @example
177
177
  # Finrb::Returns.sampling_error(sm=0.45, mu=0.5)
178
178
  def self.sampling_error(sm:, mu:)
179
- sm = Flt::DecNum(sm.to_s)
180
- mu = Flt::DecNum(mu.to_s)
179
+ sm = Validation.decimal(sm, name: 'sample mean')
180
+ mu = Validation.decimal(mu, name: 'population mean')
181
181
 
182
182
  (sm - mu)
183
183
  end
@@ -190,9 +190,9 @@ module Finrb
190
190
  # @example
191
191
  # Finrb::Returns.sf_ratio(rp=0.09,rl=0.03,sd=0.12)
192
192
  def self.sf_ratio(rp:, rl:, sd:)
193
- rp = Flt::DecNum(rp.to_s)
194
- rl = Flt::DecNum(rl.to_s)
195
- sd = Flt::DecNum(sd.to_s)
193
+ rp = Validation.decimal(rp, name: 'portfolio return')
194
+ rl = Validation.decimal(rl, name: 'threshold return')
195
+ sd = Validation.positive_decimal(sd, name: 'standard deviation', error: DomainError)
196
196
 
197
197
  ((rp - rl) / sd)
198
198
  end
@@ -205,9 +205,9 @@ module Finrb
205
205
  # @example
206
206
  # Finrb::Returns.sharpe_ratio(rp=0.038,rf=0.015,sd=0.07)
207
207
  def self.sharpe_ratio(rp:, rf:, sd:)
208
- rp = Flt::DecNum(rp.to_s)
209
- rf = Flt::DecNum(rf.to_s)
210
- sd = Flt::DecNum(sd.to_s)
208
+ rp = Validation.decimal(rp, name: 'portfolio return')
209
+ rf = Validation.decimal(rf, name: 'risk-free return')
210
+ sd = Validation.positive_decimal(sd, name: 'standard deviation', error: DomainError)
211
211
 
212
212
  ((rp - rf) / sd)
213
213
  end
@@ -220,22 +220,20 @@ module Finrb
220
220
  # @example
221
221
  # Finrb::Returns.twrr(ev=[120,260],bv=[100,240],cfr=[2,4])
222
222
  def self.twrr(ev:, bv:, cfr:)
223
- ev = wrap_array(ev).map { |value| Flt::DecNum(value.to_s) }
224
- bv = wrap_array(bv).map { |value| Flt::DecNum(value.to_s) }
225
- cfr = wrap_array(cfr).map { |value| Flt::DecNum(value.to_s) }
226
-
227
- r = ev.size
228
- s = bv.size
229
- t = cfr.size
230
- wr = Flt::DecNum(1)
231
- if r != s || r != t || s != t
232
- raise(Error, 'Different number of values!')
233
- else
234
- (0...r).each do |i|
235
- wr *= (Finrb::Returns.hpr(ev: ev[i], bv: bv[i], cfr: cfr[i]) + 1)
223
+ ending_values = risk_values(ev, name: 'ending value')
224
+ beginning_values = risk_values(bv, name: 'beginning value')
225
+ cashflows_received = risk_values(cfr, name: 'cashflow received')
226
+ sizes = [ending_values.size, beginning_values.size, cashflows_received.size]
227
+ raise(ArgumentError, 'Ending values, beginning values, and cashflows received must have equal lengths.') unless sizes.uniq.one?
228
+
229
+ wealth_relative =
230
+ ending_values.each_index.reduce(Flt::DecNum(1)) do |product, index|
231
+ period_growth = hpr(ev: ending_values[index], bv: beginning_values[index], cfr: cashflows_received[index]) + 1
232
+ raise(DomainError, 'Each subperiod wealth relative must be greater than or equal to zero.') if period_growth.negative?
233
+
234
+ product * period_growth
236
235
  end
237
- ((wr**(Flt::DecNum(1) / r)) - 1)
238
- end
236
+ (wealth_relative**(Flt::DecNum(1) / ending_values.size)) - 1
239
237
  end
240
238
 
241
239
  # Weighted mean as a portfolio return
@@ -245,13 +243,12 @@ module Finrb
245
243
  # @example
246
244
  # Finrb::Returns.wpr(r=[0.12, 0.07, 0.03],w=[0.5,0.4,0.1])
247
245
  def self.wpr(r:, w:)
248
- r = wrap_array(r).map { |value| Flt::DecNum(value.to_s) }
249
- w = wrap_array(w).map { |value| Flt::DecNum(value.to_s) }
250
-
251
- # TODO: need to change
252
- puts('sum of weights is NOT equal to 1!') if w.sum != 1
246
+ returns = risk_values(r, name: 'return')
247
+ weights = risk_values(w, name: 'weight')
248
+ raise(ArgumentError, 'Returns and weights must have equal lengths.') unless returns.size == weights.size
249
+ raise(ArgumentError, 'Weights must sum to 1.') unless weights.sum == 1
253
250
 
254
- r.zip(w).sum { |arr| arr.reduce(:*) }
251
+ returns.zip(weights).sum { |rate, weight| rate * weight }
255
252
  end
256
253
  end
257
254
  end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'calendars'
4
+ require 'date'
5
+
6
+ module Finrb
7
+ # Immutable date schedule for recurring financial payments.
8
+ class Schedule
9
+ FREQUENCY_MONTHS = { monthly: 1, quarterly: 3, semiannual: 6, annual: 12 }.freeze
10
+ STUB_CONVENTIONS = %i[none short_final].freeze
11
+ public_constant :FREQUENCY_MONTHS, :STUB_CONVENTIONS
12
+
13
+ # Shared date rules used by both the class constructor and period builder.
14
+ module DateRules
15
+ module_function
16
+
17
+ def normalize(date, name)
18
+ raise(ArgumentError, "#{name} must be a Date.") unless date.instance_of?(Date)
19
+
20
+ Date.new(date.year, date.month, date.day, Date::GREGORIAN)
21
+ end
22
+
23
+ def anchored_date(anchor, month_offset)
24
+ month_start = Date.new(anchor.year, anchor.month, 1, Date::GREGORIAN) >> month_offset
25
+ next_month_start = month_start >> 1
26
+ month_end = next_month_start - 1
27
+ anchor_month_end = (Date.new(anchor.year, anchor.month, 1, Date::GREGORIAN) >> 1) - 1
28
+ day = anchor.day == anchor_month_end.day ? month_end.day : [anchor.day, month_end.day].min
29
+ Date.new(month_start.year, month_start.month, day, Date::GREGORIAN)
30
+ end
31
+ end
32
+ private_constant :DateRules
33
+
34
+ # Immutable dates and index for one accrual/payment period.
35
+ class Period
36
+ attr_reader :index, :accrual_start_date, :unadjusted_payment_date, :payment_date, :stub
37
+
38
+ def initialize(index:, accrual_start_date:, unadjusted_payment_date:, payment_date:, stub: nil)
39
+ @index = index
40
+ @accrual_start_date = accrual_start_date
41
+ @unadjusted_payment_date = unadjusted_payment_date
42
+ @payment_date = payment_date
43
+ @stub = stub
44
+ freeze
45
+ end
46
+
47
+ def short_final_stub?
48
+ stub == :short_final
49
+ end
50
+ end
51
+
52
+ attr_reader :start_date, :maturity_date, :frequency, :stub, :calendar, :business_day_convention, :periods, :payment_dates, :unadjusted_payment_dates
53
+
54
+ def self.from_months(start_date:, term_months:, frequency: :monthly, stub: :none, calendar: nil, business_day_convention: nil)
55
+ raise(ArgumentError, 'term_months must be a positive integer.') unless term_months.is_a?(Integer) && term_months.positive?
56
+
57
+ start_date = DateRules.normalize(start_date, 'start_date')
58
+ maturity_date = DateRules.anchored_date(start_date, term_months)
59
+ new(start_date:, maturity_date:, frequency:, stub:, calendar:, business_day_convention:)
60
+ end
61
+
62
+ def initialize(start_date:, maturity_date:, frequency: :monthly, stub: :none, calendar: nil, business_day_convention: nil)
63
+ @start_date = DateRules.normalize(start_date, 'start_date')
64
+ @maturity_date = DateRules.normalize(maturity_date, 'maturity_date')
65
+ raise(ArgumentError, 'maturity_date must be after start_date.') if @maturity_date <= @start_date
66
+ raise(ArgumentError, "frequency must be one of #{FREQUENCY_MONTHS.keys.join(', ')}.") unless FREQUENCY_MONTHS.key?(frequency)
67
+ raise(ArgumentError, "stub must be one of #{STUB_CONVENTIONS.join(', ')}.") unless STUB_CONVENTIONS.include?(stub)
68
+
69
+ validate_calendar_options!(calendar, business_day_convention)
70
+ @frequency = frequency
71
+ @stub = stub
72
+ @calendar = calendar
73
+ @business_day_convention = business_day_convention
74
+ @unadjusted_payment_dates = build_unadjusted_payment_dates.freeze
75
+ @periods = build_periods.freeze
76
+ @payment_dates = @periods.map(&:payment_date).freeze
77
+ freeze
78
+ end
79
+
80
+ private
81
+
82
+ def validate_calendar_options!(calendar, convention)
83
+ raise(ArgumentError, 'business_day_convention requires a calendar.') if calendar.nil? && !convention.nil?
84
+ return if calendar.nil?
85
+
86
+ raise(ArgumentError, 'calendar must be a Finrb::Calendars::Base instance.') unless calendar.is_a?(Calendars::Base)
87
+ raise(ArgumentError, "business_day_convention must be one of #{Calendars::Base::CONVENTIONS.join(', ')}.") unless Calendars::Base::CONVENTIONS.include?(convention)
88
+ end
89
+
90
+ def build_unadjusted_payment_dates
91
+ interval = FREQUENCY_MONTHS.fetch(frequency)
92
+ dates = []
93
+ month_offset = interval
94
+ loop do
95
+ regular_date = DateRules.anchored_date(start_date, month_offset)
96
+ if regular_date < maturity_date
97
+ dates << regular_date
98
+ month_offset += interval
99
+ elsif regular_date == maturity_date
100
+ dates << regular_date
101
+ break
102
+ else
103
+ raise(ArgumentError, 'maturity_date does not align with frequency; pass stub: :short_final to allow a short final period.') unless stub == :short_final
104
+
105
+ dates << maturity_date
106
+ break
107
+ end
108
+ end
109
+ dates
110
+ end
111
+
112
+ def build_periods
113
+ previous_payment_date = start_date
114
+ unadjusted_payment_dates.each_with_index.map do |unadjusted_date, index|
115
+ regular_date = DateRules.anchored_date(start_date, FREQUENCY_MONTHS.fetch(frequency) * (index + 1))
116
+ period_stub = :short_final if index == unadjusted_payment_dates.length - 1 && regular_date != unadjusted_date
117
+ payment_date = calendar ? calendar.adjust(unadjusted_date, convention: business_day_convention) : unadjusted_date
118
+ raise(ArgumentError, 'calendar adjustment must produce dates strictly after the prior payment date.') if payment_date <= previous_payment_date
119
+
120
+ period = Period.new(index:, accrual_start_date: previous_payment_date, unadjusted_payment_date: unadjusted_date, payment_date:, stub: period_stub)
121
+ previous_payment_date = payment_date
122
+ period
123
+ end
124
+ end
125
+ end
126
+ end
data/lib/finrb/tvm.rb CHANGED
@@ -4,123 +4,225 @@ require_relative 'config'
4
4
  require_relative 'decimal'
5
5
  require_relative 'errors'
6
6
  require_relative 'numerical/brent'
7
+ require_relative 'numerical/rate_search'
8
+ require_relative 'validation'
7
9
 
8
10
  module Finrb
9
11
  # Time-value-of-money calculations for periodic rates and cashflows.
10
12
  module TVM
11
13
  module_function
12
14
 
13
- def discount_rate(n:, pv:, fv:, pmt:, type: 0, lower: 0.0001, upper: 100)
14
- n, pv, fv, pmt, type, lower, upper = decimals(n, pv, fv, pmt, type, lower, upper)
15
+ UNSET_BOUND = Object.new.freeze
16
+ private_constant :UNSET_BOUND
17
+ INPUT_NAMES = { fv: 'future value', g: 'growth rate', guess: 'rate guess', lower: 'lower rate bound', n: 'period count', pmt: 'payment', pv: 'present value', r: 'periodic rate', upper: 'upper rate bound' }.freeze
18
+ private_constant :INPUT_NAMES
19
+
20
+ def discount_rate(n:, pv:, fv:, pmt:, type: 0, guess: nil, lower: UNSET_BOUND, upper: UNSET_BOUND)
21
+ n = period_count(n)
22
+ pv, fv, pmt = decimal_inputs(pv:, fv:, pmt:).values
23
+ type = payment_type(type)
15
24
  function = ->(rate) { fv_simple(r: rate, n:, pv:) + fv_annuity(r: rate, n:, pmt:, type:) - fv }
16
25
 
17
- Numerical::Brent.new(tolerance: Finrb.config.eps).solve(function, lower:, upper:)
26
+ bounds = rate_bounds(function, guess:, lower:, upper:)
27
+ return bounds.first if bounds.first == bounds.last
28
+
29
+ Numerical::Brent.new(tolerance: Finrb.config.eps).solve(function, lower: bounds.first, upper: bounds.last)
18
30
  end
19
31
 
20
32
  def fv(r:, n:, pv: 0, pmt: 0, type: 0)
21
- r, n, pv, pmt, type = decimals(r, n, pv, pmt, type)
22
- validate_payment_type!(type)
33
+ rate = periodic_rate(r)
34
+ periods = period_count(n)
35
+ payment_type(type)
23
36
 
24
- fv_simple(r:, n:, pv:) + fv_annuity(r:, n:, pmt:, type:)
37
+ fv_simple(r: rate, n: periods, pv:) + fv_annuity(r: rate, n: periods, pmt:, type:)
25
38
  end
26
39
 
27
40
  def fv_annuity(r:, n:, pmt:, type: 0)
28
- r, n, pmt, type = decimals(r, n, pmt, type)
29
- validate_payment_type!(type)
41
+ rate = periodic_rate(r)
42
+ periods = period_count(n)
43
+ payment = Validation.decimal(pmt, name: 'payment')
44
+ payment_timing = payment_type(type)
45
+ return -payment * periods if rate.zero?
30
46
 
31
- (pmt / r * (((r + 1)**n) - 1)) * ((r + 1)**type) * -1
47
+ (payment / rate * (((rate + 1)**periods) - 1)) * ((rate + 1)**payment_timing) * -1
32
48
  end
33
49
 
34
50
  def fv_simple(r:, n:, pv:)
35
- r, n, pv = decimals(r, n, pv)
36
- (pv * ((r + 1)**n)) * -1
51
+ rate = periodic_rate(r)
52
+ periods = period_count(n)
53
+ present_value = Validation.decimal(pv, name: 'present value')
54
+ (present_value * ((rate + 1)**periods)) * -1
37
55
  end
38
56
 
39
57
  def fv_uneven(r:, cf:)
40
- r = Flt::DecNum(r.to_s)
41
- cashflows = array(cf).map { |value| Flt::DecNum(value.to_s) }
58
+ rate = periodic_rate(r)
59
+ cashflows = cashflow_values(cf)
42
60
 
43
61
  cashflows.each_with_index.sum do |cashflow, index|
44
- fv_simple(r:, n: cashflows.size - index - 1, pv: cashflow)
62
+ fv_simple(r: rate, n: cashflows.size - index - 1, pv: cashflow)
45
63
  end
46
64
  end
47
65
 
48
66
  def n_period(r:, pv:, fv:, pmt:, type: 0)
49
- r, pv, fv, pmt, type = decimals(r, pv, fv, pmt, type)
50
- validate_payment_type!(type)
67
+ rate = periodic_rate(r)
68
+ values = decimal_inputs(pv:, fv:, pmt:)
69
+ payment_timing = payment_type(type)
70
+
71
+ return zero_rate_periods(**values) if rate.zero?
72
+
73
+ numerator = ((values[:fv] * rate) - (values[:pmt] * ((rate + 1)**payment_timing))) * -1
74
+ denominator = (values[:pv] * rate) + (values[:pmt] * ((rate + 1)**payment_timing))
75
+ periods = (numerator / denominator).log / (rate + 1).log
76
+ raise(DomainError, 'Inputs do not produce a finite non-negative period count.') unless periods.finite? && !periods.negative?
51
77
 
52
- numerator = ((fv * r) - (pmt * ((r + 1)**type))) * -1
53
- denominator = (pv * r) + (pmt * ((r + 1)**type))
54
- (numerator / denominator).log / (r + 1).log
78
+ periods
79
+ rescue Flt::Num::Exception, Math::DomainError, ZeroDivisionError => e
80
+ raise(DomainError, "Inputs do not produce a real period count: #{e.message}", e.backtrace)
55
81
  end
56
82
 
57
83
  def npv(r:, cf:)
58
- cashflows = array(cf).map { |value| Flt::DecNum(value.to_s) }
59
- (pv_uneven(r:, cf: cashflows.drop(1)) * -1) + cashflows.first
84
+ rate = periodic_rate(r)
85
+ cashflows = cashflow_values(cf)
86
+ return cashflows.first if cashflows.one?
87
+
88
+ (pv_uneven(r: rate, cf: cashflows.drop(1)) * -1) + cashflows.first
60
89
  end
61
90
 
62
91
  def pmt(r:, n:, pv:, fv:, type: 0)
63
- r, n, pv, fv, type = decimals(r, n, pv, fv, type)
64
- validate_payment_type!(type)
92
+ rate = periodic_rate(r)
93
+ periods = positive_period_count(n)
94
+ values = decimal_inputs(pv:, fv:)
95
+ payment_timing = payment_type(type)
96
+ return -(values[:pv] + values[:fv]) / periods if rate.zero?
65
97
 
66
- (pv + (fv / ((r + 1)**n))) * r / (1 - (Flt::DecNum(1) / ((r + 1)**n))) * -1 * ((r + 1)**(type * -1))
98
+ (values[:pv] + (values[:fv] / ((rate + 1)**periods))) * rate / (1 - (Flt::DecNum(1) / ((rate + 1)**periods))) * -1 * ((rate + 1)**(payment_timing * -1))
67
99
  end
68
100
 
69
101
  def pv(r:, n:, fv: 0, pmt: 0, type: 0)
70
- r, n, fv, pmt, type = decimals(r, n, fv, pmt, type)
71
- validate_payment_type!(type)
102
+ rate = periodic_rate(r)
103
+ periods = period_count(n)
104
+ payment_type(type)
72
105
 
73
- pv_simple(r:, n:, fv:) + pv_annuity(r:, n:, pmt:, type:)
106
+ pv_simple(r: rate, n: periods, fv:) + pv_annuity(r: rate, n: periods, pmt:, type:)
74
107
  end
75
108
 
76
109
  def pv_annuity(r:, n:, pmt:, type: 0)
77
- r, n, pmt, type = decimals(r, n, pmt, type)
78
- validate_payment_type!(type)
110
+ rate = periodic_rate(r)
111
+ periods = period_count(n)
112
+ payment = Validation.decimal(pmt, name: 'payment')
113
+ payment_timing = payment_type(type)
114
+ return -payment * periods if rate.zero?
79
115
 
80
- (pmt / r * (1 - (Flt::DecNum(1) / ((r + 1)**n)))) * ((r + 1)**type) * -1
116
+ (payment / rate * (1 - (Flt::DecNum(1) / ((rate + 1)**periods)))) * ((rate + 1)**payment_timing) * -1
81
117
  end
82
118
 
83
119
  def pv_perpetuity(r:, pmt:, g: 0, type: 0)
84
- r, pmt, g, type = decimals(r, pmt, g, type)
85
- validate_payment_type!(type)
86
- raise(Error, 'Error: g is not smaller than r!') if g >= r
120
+ rate = periodic_rate(r)
121
+ payment = Validation.decimal(pmt, name: 'payment')
122
+ growth = periodic_rate(g, name: :g)
123
+ payment_timing = payment_type(type)
124
+ raise(DomainError, 'Growth rate must be smaller than the discount rate.') if growth >= rate
87
125
 
88
- (pmt / (r - g)) * ((r + 1)**type) * -1
126
+ (payment / (rate - growth)) * ((rate + 1)**payment_timing) * -1
89
127
  end
90
128
 
91
129
  def pv_simple(r:, n:, fv:)
92
- r, n, fv = decimals(r, n, fv)
93
- (fv / ((r + 1)**n)) * -1
130
+ rate = periodic_rate(r)
131
+ periods = period_count(n)
132
+ future_value = Validation.decimal(fv, name: 'future value')
133
+ (future_value / ((rate + 1)**periods)) * -1
94
134
  end
95
135
 
96
136
  def pv_uneven(r:, cf:)
97
- r = Flt::DecNum(r.to_s)
98
- array(cf).each_with_index.sum do |cashflow, index|
99
- pv_simple(r:, n: index + 1, fv: cashflow)
137
+ rate = periodic_rate(r)
138
+ cashflow_values(cf).each_with_index.sum do |cashflow, index|
139
+ pv_simple(r: rate, n: index + 1, fv: cashflow)
100
140
  end
101
141
  end
102
142
 
103
143
  def r_perpetuity(pmt:, pv:)
104
- pmt, pv = decimals(pmt, pv)
105
- pmt * -1 / pv
144
+ payment = Validation.decimal(pmt, name: 'payment')
145
+ present_value = Validation.decimal(pv, name: 'present value')
146
+ raise(DomainError, 'Present value must be non-zero.') if present_value.zero?
147
+
148
+ payment * -1 / present_value
106
149
  end
107
150
 
108
- def array(value)
109
- return [] if value.nil?
110
- return value.to_ary || [value] if value.respond_to?(:to_ary)
151
+ def cashflow_values(value)
152
+ values =
153
+ if value.nil?
154
+ []
155
+ elsif value.respond_to?(:to_ary)
156
+ value.to_ary || [value]
157
+ else
158
+ [value]
159
+ end
160
+ raise(ArgumentError, 'cashflows cannot be empty.') if values.empty?
161
+
162
+ values.map { |cashflow| Validation.decimal(cashflow, name: 'cashflow') }
163
+ end
164
+ private_class_method :cashflow_values
165
+
166
+ def decimal_inputs(**values)
167
+ values.to_h { |name, value| [name, Validation.decimal(value, name: input_name(name))] }
168
+ end
169
+ private_class_method :decimal_inputs
170
+
171
+ def payment_type(value)
172
+ value = Validation.decimal(value, name: 'payment timing type')
173
+ raise(ArgumentError, 'payment timing type must be 0 (end) or 1 (beginning).') unless [Flt::DecNum(0), Flt::DecNum(1)].include?(value)
174
+
175
+ value
176
+ end
177
+ private_class_method :payment_type
111
178
 
112
- [value]
179
+ def period_count(value)
180
+ Validation.non_negative_decimal(value, name: 'period count', error: DomainError)
113
181
  end
114
- private_class_method :array
182
+ private_class_method :period_count
115
183
 
116
- def decimals(*values)
117
- values.map { |value| Flt::DecNum(value.to_s) }
184
+ def positive_period_count(value)
185
+ Validation.positive_decimal(value, name: 'period count', error: DomainError)
118
186
  end
119
- private_class_method :decimals
187
+ private_class_method :positive_period_count
188
+
189
+ def periodic_rate(value, name: :r)
190
+ Validation.decimal_greater_than(value, minimum: -1, name: input_name(name), error: DomainError)
191
+ end
192
+ private_class_method :periodic_rate
193
+
194
+ def input_name(name)
195
+ INPUT_NAMES.fetch(name, name.to_s.tr('_', ' '))
196
+ end
197
+ private_class_method :input_name
198
+
199
+ def rate_bounds(function, guess:, lower:, upper:)
200
+ return searched_rate_bounds(function, guess) if lower.equal?(UNSET_BOUND) && upper.equal?(UNSET_BOUND)
201
+
202
+ lower = '0.0001' if lower.equal?(UNSET_BOUND)
203
+ upper = 100 if upper.equal?(UNSET_BOUND)
204
+ lower = periodic_rate(lower, name: :lower)
205
+ upper = periodic_rate(upper, name: :upper)
206
+ raise(ArgumentError, 'lower rate bound must be less than upper rate bound.') if lower >= upper
207
+
208
+ [lower, upper]
209
+ end
210
+ private_class_method :rate_bounds
211
+
212
+ def searched_rate_bounds(function, guess)
213
+ guess = Finrb.config.guess if guess.nil?
214
+ Numerical::RateSearch.new.bracket(function, guess: periodic_rate(guess, name: :guess))
215
+ end
216
+ private_class_method :searched_rate_bounds
217
+
218
+ def zero_rate_periods(pv:, fv:, pmt:)
219
+ raise(DomainError, 'pmt must be non-zero when solving periods at a zero rate.') if pmt.zero?
220
+
221
+ periods = (-pv - fv) / pmt
222
+ raise(DomainError, 'Inputs do not produce a non-negative period count.') if periods.negative?
120
223
 
121
- def validate_payment_type!(type)
122
- raise(Error, 'Error: type should be 0 or 1!') unless [Flt::DecNum(0), Flt::DecNum(1)].include?(type)
224
+ periods
123
225
  end
124
- private_class_method :validate_payment_type!
226
+ private_class_method :zero_rate_periods
125
227
  end
126
228
  end
@@ -23,5 +23,47 @@ module Finrb
23
23
 
24
24
  value
25
25
  end
26
+
27
+ def positive_decimal(value, name:, error: ArgumentError, message: nil)
28
+ decimal = decimal(value, name:)
29
+ raise(error, message || "#{name} must be greater than zero.") unless decimal.positive?
30
+
31
+ decimal
32
+ end
33
+
34
+ def non_negative_decimal(value, name:, error: ArgumentError, message: nil)
35
+ decimal = decimal(value, name:)
36
+ raise(error, message || "#{name} must be greater than or equal to zero.") if decimal.negative?
37
+
38
+ decimal
39
+ end
40
+
41
+ def decimal_greater_than(value, minimum:, name:, error: ArgumentError)
42
+ decimal = decimal(value, name:)
43
+ raise(error, "#{name} must be greater than #{minimum}.") if decimal <= minimum
44
+
45
+ decimal
46
+ end
47
+
48
+ def decimal_at_least(value, minimum:, name:, error: ArgumentError)
49
+ decimal = decimal(value, name:)
50
+ raise(error, "#{name} must be greater than or equal to #{minimum}.") if decimal < minimum
51
+
52
+ decimal
53
+ end
54
+
55
+ def non_zero_decimal(value, name:, error: ArgumentError)
56
+ decimal = decimal(value, name:)
57
+ raise(error, "#{name} must be non-zero.") if decimal.zero?
58
+
59
+ decimal
60
+ end
61
+
62
+ def decimal_between(value, minimum:, maximum:, name:, error: ArgumentError)
63
+ decimal = decimal(value, name:)
64
+ raise(error, "#{name} must be between #{minimum} and #{maximum}, inclusive.") unless decimal.between?(minimum, maximum)
65
+
66
+ decimal
67
+ end
26
68
  end
27
69
  end
data/lib/finrb/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Finrb
4
- VERSION = '1.1.0'
4
+ VERSION = '1.3.0'
5
5
  public_constant :VERSION
6
6
  end