finrb 1.0.1 → 1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b167e3a39320ed5f8026b7451063d08f6eeb8e1047dc06f7688e4afc617434c2
4
- data.tar.gz: 9407136f5589353ab3704f9bd7bb37b802191aa3aa51ca2bc3248cf359a276da
3
+ metadata.gz: 5331d4b4769cc183d279c1eb4d837d05137cb6b09dd69394addb7be0c6debfae
4
+ data.tar.gz: de97a959862d55711e7e6989dc8c47ffa277dcea32cc8184cc89f2158edad160
5
5
  SHA512:
6
- metadata.gz: 9ec1ff7159e4d1aadd5dde4fcb168a0f1a7679e20f59c50bb26a642ccbee1e6aa65b021e67906586382e64aca23a240e566625ea359dc48e9aa2987e24a9c433
7
- data.tar.gz: 671ae01b065e636c0bf8ef60e851e53866b02e09238d3a233443150a11f09851af493a2462e891a305ce174c6cb57ba492f09f6cfef48ded3693a120e448c410
6
+ metadata.gz: e57aeeac9a952790c895d367170596097d15fe5cbdb43bcab6a9943bfa80bff0a3a4d1e9d016e859d6f554b7a892a78ee2b3896f961b850f9ac1fafd7e064948
7
+ data.tar.gz: 84e26bbd5a9862c6733cbce98cebe4ebe1640751992c6fffe74d33ba31b39dc77f0f0ca5cf4ec8b6fd6bb6d0c62fa5bab1cbbacd3b0a8384d4d1eb1573b9528d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # finrb changelog
2
2
 
3
+ ## 1.1.0
4
+
5
+ ### Investment returns and risk
6
+
7
+ - Add compound annual growth rate (CAGR) with explicit value and period-domain validation.
8
+ - Add modified internal rate of return (MIRR) with separate financing and reinvestment rates.
9
+ - Add sample and population volatility, downside deviation, Sortino ratio, and maximum drawdown.
10
+ - Add compound-return and square-root-of-time volatility annualization helpers.
11
+ - Define the statistical conventions explicitly: volatility is sample-based by default, downside deviation includes all observations in its denominator, and maximum drawdown is returned as a non-negative loss fraction.
12
+
13
+ ### Loan schedules
14
+
15
+ - Expose each amortization period as an immutable `Finrb::Amortization::Entry` containing its period, opening and closing balances, payment, interest, principal, additional principal, balloon settlement, and interest-only state.
16
+ - Preserve the existing cashflow convention: payments are negative, while balances, interest, principal repaid, and additional principal are non-negative.
17
+ - Add contractual balloon targets. Regular installments amortize toward the target and the final payment settles the residual, including cent-rounding reconciliation.
18
+ - Add leading interest-only periods, including zero-rate periods and combinations with balloon loans. Remaining principal amortizes over the rest of the term.
19
+ - Add upfront and financed origination fees with separate `principal`, `net_proceeds`, and `amount_financed` values. Financed fees enter the opening balance; upfront fees reduce borrower proceeds.
20
+ - Correct schedule period numbering across rate segments and reused payment templates.
21
+
22
+ ### API and documentation
23
+
24
+ - Add RBS declarations and API examples for all new return metrics and amortization features.
25
+ - Refine README compatibility badges to identify tested MRI versions, x86-64/ARM64 architectures, and experimental JRuby/TruffleRuby coverage.
26
+ - Remove redundant YARD `@api` annotations and make the internal amortization calculation methods genuinely private.
27
+
3
28
  ## 1.0.1
4
29
 
5
30
  ### Runtime compatibility
data/README.md CHANGED
@@ -3,6 +3,9 @@
3
3
  [![CI](https://github.com/ncs1/finrb/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/ncs1/finrb/actions/workflows/ci.yml)
4
4
  [![CodeQL](https://github.com/ncs1/finrb/actions/workflows/codeql.yml/badge.svg)](https://github.com/ncs1/finrb/actions/workflows/codeql.yml)
5
5
  [![RuboCop](https://github.com/ncs1/finrb/actions/workflows/rubocop.yml/badge.svg)](https://github.com/ncs1/finrb/actions/workflows/rubocop.yml)
6
+ [![MRI](https://img.shields.io/badge/MRI-3.3_%7C_3.4_%7C_4.0-CC342D?logo=ruby&logoColor=white)](https://github.com/ncs1/finrb/actions/workflows/ci.yml)
7
+ [![Architectures](https://img.shields.io/badge/architectures-x86__64_%7C_arm64-4169E1)](https://github.com/ncs1/finrb/actions/workflows/ci.yml)
8
+ [![Experimental engines](https://img.shields.io/badge/experimental-JRuby_%7C_TruffleRuby-F0AD4E)](https://github.com/ncs1/finrb/actions/workflows/ci.yml)
6
9
 
7
10
  Precision-first financial mathematics for Ruby.
8
11
 
@@ -117,6 +120,15 @@ loan = Finrb::Amortization.new(250_000, rate)
117
120
  loan.payment # => Flt::DecNum('-1229.85')
118
121
  loan.interest.sum # => Flt::DecNum('192745.98')
119
122
  loan.balance # => Flt::DecNum('0.00')
123
+
124
+ first = loan.schedule.first
125
+ first.opening_balance
126
+ first.interest
127
+ first.principal
128
+ first.payment
129
+ first.balloon_payment
130
+ first.interest_only?
131
+ first.closing_balance
120
132
  ```
121
133
 
122
134
  Pass several duration-bearing rates for an adjustable-rate schedule. A block
@@ -130,6 +142,48 @@ end
130
142
 
131
143
  Payments and interest follow the sign convention used throughout finrb:
132
144
  money received is positive and money paid is negative.
145
+ Schedule balances, interest, principal repaid, and additional principal are
146
+ non-negative; the schedule's payment field is negative.
147
+
148
+ Set a residual principal target to create a balloon loan. Regular installments
149
+ amortize only the non-balloon portion, and the final payment settles the stated
150
+ balloon:
151
+
152
+ ```ruby
153
+ balloon_loan = Finrb::Amortization.new(250_000, rate, balloon: 100_000)
154
+ balloon_loan.schedule.last.balloon_payment # => Flt::DecNum('100000')
155
+ ```
156
+
157
+ `balloon` is the contractual residual target. Because regular postings are
158
+ rounded to cents, the actual `balloon_payment` in the final schedule row can
159
+ differ from that target by a few cents.
160
+
161
+ Leading interest-only periods defer scheduled principal repayment and amortize
162
+ the balance over the remaining term:
163
+
164
+ ```ruby
165
+ interest_only = Finrb::Amortization.new(250_000, rate, interest_only_periods: 24)
166
+ interest_only.schedule.first.interest_only? # => true
167
+ interest_only.schedule.first.principal # => Flt::DecNum('0')
168
+ ```
169
+
170
+ Origination fees can either reduce the borrower's net proceeds or be added to
171
+ the financed balance:
172
+
173
+ ```ruby
174
+ cash_fee = Finrb::Amortization.new(250_000, rate, origination_fee: 2_500)
175
+ cash_fee.net_proceeds # => Flt::DecNum('247500')
176
+ cash_fee.amount_financed # => Flt::DecNum('250000')
177
+
178
+ financed_fee = Finrb::Amortization.new(
179
+ 250_000,
180
+ rate,
181
+ origination_fee: 2_500,
182
+ finance_origination_fee: true
183
+ )
184
+ financed_fee.net_proceeds # => Flt::DecNum('250000')
185
+ financed_fee.amount_financed # => Flt::DecNum('252500')
186
+ ```
133
187
 
134
188
  ## Configuration
135
189
 
@@ -18,20 +18,67 @@ module Finrb
18
18
  # @example Borrow $250,000 under a 30 year, fixed-rate loan with a 4.25% APR, but pay $150 extra each month
19
19
  # rate = Rate.new(0.0425, :apr, :duration => (5 * 12))
20
20
  # extra_payments = Finrb::Amortization.new(250000, rate){ |period| period.payment - 150 }
21
- # @api public
22
21
  class Amortization
22
+ # Immutable breakdown of one amortization period. Payments retain finrb's
23
+ # cashflow sign convention and are negative; the other monetary fields are
24
+ # non-negative.
25
+ class Entry
26
+ ATTRIBUTES = %i[period opening_balance payment interest principal additional_payment balloon_payment interest_only closing_balance].freeze
27
+ MONETARY_ATTRIBUTES = ATTRIBUTES - %i[period interest_only]
28
+ private_constant :ATTRIBUTES, :MONETARY_ATTRIBUTES
29
+
30
+ attr_reader(*ATTRIBUTES)
31
+
32
+ def initialize(period:, opening_balance:, payment:, interest:, principal:, additional_payment:, balloon_payment:, interest_only:, closing_balance:)
33
+ raise(ArgumentError, 'period must be a non-negative integer.') unless period.is_a?(Integer) && !period.negative?
34
+ raise(ArgumentError, 'interest_only must be true or false.') unless [true, false].include?(interest_only)
35
+
36
+ @period = period
37
+ @interest_only = interest_only
38
+ MONETARY_ATTRIBUTES.each do |name|
39
+ value = binding.local_variable_get(name)
40
+ instance_variable_set("@#{name}", Validation.decimal(value, name: name.to_s))
41
+ end
42
+ freeze
43
+ end
44
+
45
+ def ==(other)
46
+ other.instance_of?(self.class) && ATTRIBUTES.all? { |name| public_send(name) == other.public_send(name) }
47
+ end
48
+ alias eql? ==
49
+
50
+ def hash
51
+ attributes = ATTRIBUTES.map { |name| public_send(name) }
52
+ attributes.hash
53
+ end
54
+
55
+ def to_h
56
+ ATTRIBUTES.to_h { |name| [name, public_send(name)] }
57
+ end
58
+
59
+ alias interest_only? interest_only
60
+ end
61
+
23
62
  # @return [Flt::DecNum] the balance of the loan at the end of the amortization period (usually zero)
24
- # @api public
25
63
  attr_reader :balance
64
+ # @return [Flt::DecNum] contractual principal settled as a balloon in the final period
65
+ attr_reader :balloon
66
+ # @return [Flt::DecNum] principal balance including any financed origination fee
67
+ attr_reader :amount_financed
68
+ # @return [Flt::DecNum] cash made available to the borrower after an unfinanced fee
69
+ attr_reader :net_proceeds
70
+ # @return [Flt::DecNum] fee charged when the loan is originated
71
+ attr_reader :origination_fee
72
+ # @return [Integer] number of leading periods that pay interest but no scheduled principal
73
+ attr_reader :interest_only_periods
26
74
  # @return [Flt::DecNum] the required monthly payment. For loans with more than one rate, returns nil
27
- # @api public
28
75
  attr_reader :payment
29
76
  # @return [Flt::DecNum] the principal amount of the loan
30
- # @api public
31
77
  attr_reader :principal
32
78
  # @return [Array] the interest rates used for calculating the amortization
33
- # @api public
34
79
  attr_reader :rates
80
+ # @return [Array<Entry>] immutable period-by-period loan breakdown
81
+ attr_reader :schedule
35
82
 
36
83
  # @return [Flt::DecNum] the periodic payment due on a loan
37
84
  # @param [Flt::DecNum] principal the initial amount of the loan or investment
@@ -43,11 +90,13 @@ module Finrb
43
90
  # rate.duration #=> 360
44
91
  # Amortization.payment(200000, rate.monthly, rate.duration) #=> Flt::DecNum('-926.23')
45
92
  # @see https://en.wikipedia.org/wiki/Amortization_calculator
46
- # @api public
47
- def self.payment(principal, rate, periods)
93
+ def self.payment(principal, rate, periods, balloon: 0)
48
94
  principal = Validation.decimal(principal, name: 'principal')
49
95
  raise(ArgumentError, 'principal must be positive.') unless principal.positive?
50
96
 
97
+ balloon = Validation.decimal(balloon, name: 'balloon')
98
+ raise(ArgumentError, 'balloon must be non-negative and no greater than principal.') unless balloon.between?(0, principal)
99
+
51
100
  rate = Validation.decimal(rate, name: 'rate')
52
101
  raise(ArgumentError, 'periodic rate must be greater than -1.') if rate <= -1
53
102
 
@@ -55,9 +104,10 @@ module Finrb
55
104
 
56
105
  if rate.zero?
57
106
  # simplified formula to avoid division-by-zero when interest rate is zero
58
- -Precision.money(principal / periods)
107
+ -Precision.money((principal - balloon) / periods)
59
108
  else
60
- -Precision.money(principal * (rate + (rate / (((rate + 1)**periods) - 1))))
109
+ growth = (rate + 1)**periods
110
+ -Precision.money(((principal * growth) - balloon) * rate / (growth - 1))
61
111
  end
62
112
  end
63
113
 
@@ -66,10 +116,21 @@ module Finrb
66
116
  # @param [Flt::DecNum] principal the initial amount of the loan or investment
67
117
  # @param [Rate] rates the applicable interest rates
68
118
  # @param [Proc] block
69
- # @api public
70
- def initialize(principal, *rates, &block)
119
+ def initialize(principal, *rates, balloon: 0, interest_only_periods: 0, origination_fee: 0, finance_origination_fee: false, &block)
71
120
  @principal = Validation.decimal(principal, name: 'principal')
72
121
  raise(ArgumentError, 'principal must be positive.') unless @principal.positive?
122
+
123
+ @origination_fee = Validation.decimal(origination_fee, name: 'origination_fee')
124
+ raise(ArgumentError, 'origination_fee must be non-negative.') if @origination_fee.negative?
125
+ raise(ArgumentError, 'finance_origination_fee must be true or false.') unless [true, false].include?(finance_origination_fee)
126
+ raise(ArgumentError, 'an unfinanced origination_fee must be less than principal.') if !finance_origination_fee && @origination_fee >= @principal
127
+
128
+ @finance_origination_fee = finance_origination_fee
129
+ @amount_financed = @principal + (finance_origination_fee ? @origination_fee : 0)
130
+ @net_proceeds = @principal - (finance_origination_fee ? 0 : @origination_fee)
131
+
132
+ @balloon = Validation.decimal(balloon, name: 'balloon')
133
+ raise(ArgumentError, 'balloon must be non-negative and less than amount financed.') if @balloon.negative? || @balloon >= @amount_financed
73
134
  raise(ArgumentError, 'at least one rate is required.') if rates.empty?
74
135
  raise(ArgumentError, 'rates must be Finrb::Rate instances.') unless rates.all?(Rate)
75
136
  raise(ArgumentError, 'every rate must have a duration.') if rates.any? { |rate| rate.duration.nil? }
@@ -79,7 +140,11 @@ module Finrb
79
140
 
80
141
  # compute the total duration from all of the rates.
81
142
  @periods = rates.sum(&:duration)
82
- @period = 0
143
+ valid_interest_only = interest_only_periods.is_a?(Integer) && interest_only_periods.between?(0, @periods - 1)
144
+ raise(ArgumentError, 'interest_only_periods must be a non-negative integer shorter than the loan term.') unless valid_interest_only
145
+
146
+ @interest_only_periods = interest_only_periods
147
+ @period = 0
83
148
 
84
149
  compute
85
150
  end
@@ -87,17 +152,18 @@ module Finrb
87
152
  # compare two Amortization instances
88
153
  # @return [Numeric] -1, 0, or +1
89
154
  # @param [Amortization] other
90
- # @api public
91
155
  def ==(other)
92
- (principal == other.principal) && (rates == other.rates) && (payments == other.payments)
156
+ (principal == other.principal) && (origination_fee == other.origination_fee) && (finance_origination_fee? == other.finance_origination_fee?) && (balloon == other.balloon) && (interest_only_periods == other.interest_only_periods) && (rates == other.rates) && (payments == other.payments)
93
157
  end
94
158
 
159
+ attr_reader :finance_origination_fee
160
+ alias finance_origination_fee? finance_origination_fee
161
+
95
162
  # @return [Array] the amount of any additional payments in each period
96
163
  # @example
97
164
  # rate = Rate.new(0.0375, :apr, :duration => (30 * 12))
98
165
  # amt = Finrb::Amortization.new(300000, rate){ |payment| payment.amount-100}
99
166
  # amt.additional_payments #=> [Flt::DecNum('-100.00'), Flt::DecNum('-100.00'), ... ]
100
- # @api public
101
167
  def additional_payments
102
168
  @transactions.filter_map { |trans| trans.difference if trans.payment? }
103
169
  end
@@ -105,32 +171,29 @@ module Finrb
105
171
  # amortize the balance of loan with the given interest rate
106
172
  # @return none
107
173
  # @param [Rate] rate the interest rate to use in the amortization
108
- # @api private
109
174
  def amortize(rate)
110
- # For the purposes of calculating a payment, the relevant time
111
- # period is the remaining number of periods in the loan, not
112
- # necessarily the duration of the rate itself.
113
- periods = @periods - @period
114
- amount = Amortization.payment(@balance, rate.monthly, periods)
115
-
116
- pmt = Payment.new(amount, period: @period)
117
- pmt.modify(&@block) if @block
118
- raise(ArgumentError, 'payment modification must produce a negative amount.') unless pmt.amount.negative?
175
+ regular_payment = nil
119
176
 
120
177
  rate.duration.to_i.times do
121
178
  # Do this first in case the balance is zero already.
122
179
  break if @balance.zero?
123
180
 
181
+ interest_only = @period < @interest_only_periods
182
+ regular_payment ||= build_regular_payment(rate) unless interest_only
183
+
124
184
  # Compute and record interest on the outstanding balance.
125
185
  int = Precision.money(@balance * rate.monthly)
126
186
  interest = Interest.new(int, period: @period)
127
187
  @balance += interest.amount
128
188
  @transactions << interest.dup
129
189
 
130
- # Record payment. Don't pay more than the outstanding balance.
131
- pmt.amount = -@balance if pmt.amount.abs > @balance
132
- @transactions << pmt.dup
133
- @balance += pmt.amount
190
+ payment = interest_only ? build_interest_only_payment(int) : regular_payment
191
+ payment.period = @period
192
+ payment.amount = -@balance if payment.amount.abs > @balance
193
+ @additional_by_period << [-payment.difference, Flt::DecNum(0)].max
194
+ @interest_only_by_period << interest_only
195
+ @transactions << payment.dup
196
+ @balance += payment.amount
134
197
 
135
198
  @period += 1
136
199
  end
@@ -138,26 +201,35 @@ module Finrb
138
201
 
139
202
  # compute the amortization of the principal
140
203
  # @return none
141
- # @api private
142
204
  def compute
143
- @balance = @principal
205
+ @balance = @amount_financed
144
206
  @transactions = []
207
+ @additional_by_period = []
208
+ @interest_only_by_period = []
145
209
 
146
210
  @rates.each do |rate|
147
211
  amortize(rate)
148
212
  end
149
213
 
150
- # Add any remaining balance due to rounding error to the last payment.
214
+ # Add the residual balloon and any rounding remainder to the last payment.
215
+ @balloon_by_period = Array.new(@additional_by_period.length, Flt::DecNum(0))
151
216
  if @balance.nonzero?
217
+ @balloon_by_period[-1] = [@balloon, @balance].min
152
218
  @transactions.reverse.find(&:payment?).amount -= @balance
153
219
  @balance = 0
154
220
  end
155
221
 
156
- @payment = (payments.first if @rates.length == 1)
222
+ @payment = (payments.first if @rates.length == 1 && @interest_only_periods.zero?)
157
223
 
158
224
  @transactions.freeze
225
+ @additional_by_period.freeze
226
+ @balloon_by_period.freeze
227
+ @interest_only_by_period.freeze
228
+ @schedule = build_schedule.freeze
159
229
  end
160
230
 
231
+ private :amortize, :compute
232
+
161
233
  # @return [Integer] the time required to pay off the loan, in months
162
234
  # @example In most cases, the duration is equal to the total duration of all rates
163
235
  # rate = Rate.new(0.0375, :apr, :duration => (30 * 12))
@@ -167,12 +239,10 @@ module Finrb
167
239
  # rate = Rate.new(0.0375, :apr, :duration => (30 * 12))
168
240
  # amt = Finrb::Amortization.new(300000, rate){ |payment| payment.amount-100}
169
241
  # amt.duration #=> 319
170
- # @api public
171
242
  def duration
172
243
  payments.length
173
244
  end
174
245
 
175
- # @api public
176
246
  def inspect
177
247
  "Amortization.new(#{@principal})"
178
248
  end
@@ -186,7 +256,6 @@ module Finrb
186
256
  # rate = Rate.new(0.0375, :apr, :duration => (30 * 12))
187
257
  # amt = Finrb::Amortization.new(300000, rate)
188
258
  # amt.interest[0,6].sum #=> Flt::DecNum('5603.74')
189
- # @api public
190
259
  def interest
191
260
  @transactions.filter_map { |trans| trans.amount if trans.interest? }
192
261
  end
@@ -196,9 +265,45 @@ module Finrb
196
265
  # rate = Rate.new(0.0375, :apr, :duration => (30 * 12))
197
266
  # amt = Finrb::Amortization.new(300000, rate)
198
267
  # amt.payments.sum #=> Flt::DecNum('-500163.94')
199
- # @api public
200
268
  def payments
201
269
  @transactions.filter_map { |trans| trans.amount if trans.payment? }
202
270
  end
271
+
272
+ private
273
+
274
+ def build_schedule
275
+ opening_balance = @amount_financed
276
+ @transactions.each_slice(2).with_index.map do |(interest, payment), index|
277
+ principal = -(payment.amount + interest.amount)
278
+ closing_balance = opening_balance - principal
279
+ entry = Entry.new(period: payment.period, opening_balance:, payment: payment.amount, interest: interest.amount, principal:, additional_payment: @additional_by_period.fetch(index), balloon_payment: @balloon_by_period.fetch(index), interest_only: @interest_only_by_period.fetch(index), closing_balance:)
280
+ opening_balance = closing_balance
281
+ entry
282
+ end
283
+ end
284
+
285
+ def build_regular_payment(rate)
286
+ periods = @periods - @period
287
+ amount = Amortization.payment(@balance, rate.monthly, periods, balloon: @balloon)
288
+ Payment.new(amount, period: @period).tap do |payment|
289
+ payment.modify(&@block) if @block
290
+ validate_payment!(payment)
291
+ end
292
+ end
293
+
294
+ def build_interest_only_payment(interest)
295
+ Payment.new(-interest, period: @period).tap do |payment|
296
+ payment.modify(&@block) if @block
297
+ validate_payment!(payment, allow_zero: true)
298
+ end
299
+ end
300
+
301
+ def validate_payment!(payment, allow_zero: false)
302
+ valid = payment.amount.negative? || (allow_zero && payment.amount.zero?)
303
+ return if valid
304
+
305
+ requirement = allow_zero ? 'must not produce a positive amount' : 'must produce a negative amount'
306
+ raise(ArgumentError, "payment modification #{requirement}.")
307
+ end
203
308
  end
204
309
  end
@@ -12,7 +12,6 @@ require 'date'
12
12
 
13
13
  module Finrb
14
14
  # Provides methods for working with cash flows (collections of transactions)
15
- # @api public
16
15
  module Cashflow
17
16
  class << self
18
17
  def irr(cashflows, guess = nil)
@@ -23,6 +22,10 @@ module Finrb
23
22
  sequence(cashflows).npv(rate)
24
23
  end
25
24
 
25
+ def mirr(cashflows, finance_rate:, reinvestment_rate:)
26
+ sequence(cashflows).mirr(finance_rate:, reinvestment_rate:)
27
+ end
28
+
26
29
  def xirr(transactions, guess = nil)
27
30
  sequence(transactions).xirr(guess)
28
31
  end
@@ -55,7 +58,6 @@ module Finrb
55
58
  # @example
56
59
  # Finrb::Cashflow.irr([-4000,1200,1410,1875,1050]) #=> 0.143
57
60
  # @see https://en.wikipedia.org/wiki/Internal_rate_of_return
58
- # @api public
59
61
  def irr(guess = nil)
60
62
  validate_numeric_cashflows!
61
63
 
@@ -71,7 +73,6 @@ module Finrb
71
73
  # @example
72
74
  # Finrb::Cashflow.npv([-100.0, 60, 60, 60], 0.1) #=> 49.211
73
75
  # @see https://en.wikipedia.org/wiki/Net_present_value
74
- # @api public
75
76
  def npv(rate)
76
77
  validate_numeric_cashflows!
77
78
  cashflows = map { |entry| Validation.decimal(entry, name: 'cashflow amount') }
@@ -87,6 +88,33 @@ module Finrb
87
88
  total
88
89
  end
89
90
 
91
+ # Calculate the modified internal rate of return for equally spaced
92
+ # cashflows using separate financing and reinvestment assumptions.
93
+ # @return [Flt::DecNum] modified per-period internal rate of return
94
+ def mirr(finance_rate:, reinvestment_rate:)
95
+ validate_numeric_cashflows!
96
+ raise(InvalidCashflowError, 'MIRR requires at least two cashflows.') if size < 2
97
+
98
+ cashflows = map { |entry| Validation.decimal(entry, name: 'cashflow amount') }
99
+ raise(InvalidCashflowError, 'Cashflow needs at least one positive and one negative value.') if cashflows.none?(&:positive?) || cashflows.none?(&:negative?)
100
+
101
+ finance_rate = Validation.decimal(finance_rate, name: 'finance_rate')
102
+ reinvestment_rate = Validation.decimal(reinvestment_rate, name: 'reinvestment_rate')
103
+ raise(DomainError, 'Finance and reinvestment rates must be greater than -1.') if finance_rate <= -1 || reinvestment_rate <= -1
104
+
105
+ last_period = cashflows.size - 1
106
+ future_positive =
107
+ cashflows.each_with_index.sum do |amount, index|
108
+ amount.positive? ? amount * ((reinvestment_rate + 1)**(last_period - index)) : Flt::DecNum(0)
109
+ end
110
+ present_negative =
111
+ cashflows.each_with_index.sum do |amount, index|
112
+ amount.negative? ? amount / ((finance_rate + 1)**index) : Flt::DecNum(0)
113
+ end
114
+
115
+ ((future_positive / -present_negative)**(Flt::DecNum(1) / last_period)) - 1
116
+ end
117
+
90
118
  # Calculate the effective annual internal rate of return for an ordered
91
119
  # sequence of dated transactions.
92
120
  #
@@ -108,7 +136,6 @@ module Finrb
108
136
  # @transactions << Transaction.new( 600, :date => Time.new(1990,01,01))
109
137
  # @transactions << Transaction.new( 600, :date => Time.new(1995,01,01))
110
138
  # Finrb::Cashflow.xirr(@transactions, 0.6) #=> Rate("0.024851", :effective, :compounds => :annually)
111
- # @api public
112
139
  def xirr(guess = nil)
113
140
  validate_dated_cashflows!
114
141
 
@@ -127,7 +154,6 @@ module Finrb
127
154
  # @transactions << Transaction.new( 600, :date => Time.new(1990,01,01))
128
155
  # @transactions << Transaction.new( 600, :date => Time.new(1995,01,01))
129
156
  # Finrb::Cashflow.xnpv(@transactions, 0.6).round(2) #=> -937.41
130
- # @api public
131
157
  def xnpv(rate)
132
158
  validate_dated_cashflows!
133
159
  rate = Validation.decimal(rate, name: 'rate')
data/lib/finrb/rates.rb CHANGED
@@ -6,7 +6,6 @@ require_relative 'validation'
6
6
  module Finrb
7
7
  # the Rate class provides an interface for working with interest rates.
8
8
  # {render:Rate#new}
9
- # @api public
10
9
  class Rate
11
10
  include Comparable
12
11
 
@@ -31,7 +30,6 @@ module Finrb
31
30
  # @param [Numeric] periods the number of compounding periods per year
32
31
  # @example
33
32
  # Rate.to_effective(0.05, 4) #=> Flt::DecNum('0.05095')
34
- # @api public
35
33
  def self.to_effective(rate, periods)
36
34
  rate = Validation.decimal(rate, name: 'rate')
37
35
  periods = compounding_periods(periods)
@@ -50,7 +48,6 @@ module Finrb
50
48
  # @example
51
49
  # Rate.to_nominal(0.06, 365) #=> Flt::DecNum('0.05827')
52
50
  # @see https://www.miniwebtool.com/nominal-interest-rate-calculator/
53
- # @api public
54
51
  def self.to_nominal(rate, periods)
55
52
  rate = Validation.decimal(rate, name: 'rate')
56
53
  raise(ArgumentError, 'effective rate must be greater than -1.') if rate <= -1
@@ -75,7 +72,6 @@ module Finrb
75
72
  # Rate.new(0.035, :apr) #=> Rate(0.035, :apr)
76
73
  # @see https://en.wikipedia.org/wiki/Effective_interest_rate
77
74
  # @see https://en.wikipedia.org/wiki/Nominal_interest_rate
78
- # @api public
79
75
  def initialize(rate, type, opts = {})
80
76
  raise(ArgumentError, 'options must be a Hash.') unless opts.is_a?(Hash)
81
77
  raise(ArgumentError, 'options may only contain compounds and duration.') unless (opts.keys - %i[compounds duration]).empty?
@@ -97,13 +93,10 @@ module Finrb
97
93
  end
98
94
 
99
95
  # @return [Integer] the duration for which the rate is valid, in months
100
- # @api public
101
96
  attr_reader :duration
102
97
  # @return [Flt::DecNum] the effective interest rate
103
- # @api public
104
98
  attr_reader :effective
105
99
  # @return [Flt::DecNum] the nominal interest rate
106
- # @api public
107
100
  attr_reader :nominal
108
101
 
109
102
  # compare two Rates, using the effective rate
@@ -113,21 +106,18 @@ module Finrb
113
106
  # r1 = Rate.new(0.15, :nominal) #=> Rate.new(0.160755, :apr)
114
107
  # r2 = Rate.new(0.155, :nominal, :compounds => :semiannually) #=> Rate.new(0.161006, :apr)
115
108
  # r1 <=> r2 #=> -1
116
- # @api public
117
109
  def <=>(other)
118
110
  @effective <=> other.effective
119
111
  end
120
112
 
121
113
  # Return the nominal annual percentage rate for the configured compounding frequency.
122
114
  # @return [Flt::DecNum] the nominal annual percentage rate
123
- # @api public
124
115
  def apr
125
116
  nominal
126
117
  end
127
118
 
128
119
  # Return the effective annual percentage yield.
129
120
  # @return [Flt::DecNum] the effective annual percentage yield
130
- # @api public
131
121
  def apy
132
122
  effective
133
123
  end
@@ -136,7 +126,6 @@ module Finrb
136
126
  # @return none
137
127
  # @param [Symbol, Numeric] input the compounding frequency
138
128
  # @raise [ArgumentError] if input is not an accepted keyword or Numeric
139
- # @api private
140
129
  def compounds=(input)
141
130
  @periods =
142
131
  case input
@@ -158,7 +147,6 @@ module Finrb
158
147
  # set the effective interest rate
159
148
  # @return none
160
149
  # @param [Flt::DecNum] rate the effective interest rate
161
- # @api private
162
150
  def effective=(rate)
163
151
  raise(ArgumentError, 'effective rate must be greater than -1.') if rate <= -1
164
152
 
@@ -176,7 +164,6 @@ module Finrb
176
164
  # rate.apr.round(6) #=> Flt::DecNum('0.15')
177
165
  # rate.apy.round(6) #=> Flt::DecNum('0.160755')
178
166
  # rate.monthly.round(6) #=> Flt::DecNum('0.0125')
179
- # @api public
180
167
  def monthly
181
168
  @monthly ||= Precision.rate(Rate.to_nominal(effective, 12) / 12)
182
169
  end
@@ -184,7 +171,6 @@ module Finrb
184
171
  # set the nominal interest rate
185
172
  # @return none
186
173
  # @param [Flt::DecNum] rate the nominal interest rate
187
- # @api private
188
174
  def nominal=(rate)
189
175
  raise(ArgumentError, 'nominal rate must keep every compounded period greater than -100%.') if !@periods.infinite? && rate <= -@periods
190
176
 
data/lib/finrb/returns.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'decimal'
4
4
  require_relative 'errors'
5
+ require_relative 'validation'
5
6
 
6
7
  module Finrb
7
8
  # Investment return and risk-adjusted performance calculations.
@@ -17,6 +18,107 @@ module Finrb
17
18
  end
18
19
  private_class_method :wrap_array
19
20
 
21
+ # Compound annual growth rate over a positive number of periods.
22
+ #
23
+ # Beginning value must be positive. Ending value may be zero, representing
24
+ # a total loss, but cannot be negative because a fractional growth root
25
+ # would not have a generally meaningful real-valued result.
26
+ #
27
+ # @param beginning_value [Numeric] value at the start of the measurement
28
+ # @param ending_value [Numeric] value at the end of the measurement
29
+ # @param periods [Integer] number of equal annual periods
30
+ # @return [Flt::DecNum] compound growth rate per period
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?
38
+
39
+ ((ending_value / beginning_value)**(Flt::DecNum(1) / periods)) - 1
40
+ end
41
+
42
+ def self.risk_values(values, name:)
43
+ values = wrap_array(values)
44
+ raise(ArgumentError, "#{name} cannot be empty.") if values.empty?
45
+
46
+ values.map { |value| Validation.decimal(value, name:) }
47
+ end
48
+ private_class_method :risk_values
49
+
50
+ # Compound a periodic return into an annual return.
51
+ 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
55
+
56
+ ((rate + 1)**periods_per_year) - 1
57
+ end
58
+
59
+ # Scale periodic volatility by the square root of periods per year.
60
+ 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?
64
+
65
+ volatility * (Flt::DecNum(periods_per_year)**Flt::DecNum('0.5'))
66
+ end
67
+
68
+ # Standard deviation of periodic returns. Sample volatility uses n - 1;
69
+ # population volatility uses n.
70
+ def self.volatility(returns:, sample: true)
71
+ raise(ArgumentError, 'sample must be true or false.') unless [true, false].include?(sample)
72
+
73
+ returns = risk_values(returns, name: 'return')
74
+ raise(ArgumentError, 'sample volatility requires at least two returns.') if sample && returns.size < 2
75
+
76
+ mean = returns.sum / returns.size
77
+ denominator = sample ? returns.size - 1 : returns.size
78
+ variance = returns.sum { |value| (value - mean)**2 } / denominator
79
+ variance**Flt::DecNum('0.5')
80
+ end
81
+
82
+ # Root-mean-square return shortfall below a target return. The denominator
83
+ # includes every observation, including returns at or above the target.
84
+ def self.downside_deviation(returns:, target: 0)
85
+ returns = risk_values(returns, name: 'return')
86
+ target = Validation.decimal(target, name: 'target')
87
+ squared_shortfalls =
88
+ returns.sum do |value|
89
+ shortfall = [value - target, Flt::DecNum(0)].min
90
+ shortfall**2
91
+ end
92
+
93
+ (squared_shortfalls / returns.size)**Flt::DecNum('0.5')
94
+ end
95
+
96
+ # Sortino ratio using arithmetic mean excess return and downside deviation.
97
+ def self.sortino_ratio(returns:, target: 0, periods_per_year: nil)
98
+ returns = risk_values(returns, name: 'return')
99
+ target = Validation.decimal(target, name: 'target')
100
+ downside = downside_deviation(returns:, target:)
101
+ raise(ArgumentError, 'downside deviation must be greater than zero.') if downside.zero?
102
+
103
+ ratio = ((returns.sum / returns.size) - target) / downside
104
+ return ratio if periods_per_year.nil?
105
+
106
+ periods_per_year = Validation.positive_integer(periods_per_year, name: 'periods_per_year')
107
+ ratio * (Flt::DecNum(periods_per_year)**Flt::DecNum('0.5'))
108
+ end
109
+
110
+ # Largest peak-to-trough decline as a non-negative fraction.
111
+ def self.max_drawdown(values:)
112
+ values = risk_values(values, name: 'value')
113
+ raise(ArgumentError, 'values must be greater than zero.') unless values.all?(&:positive?)
114
+
115
+ peak = values.first
116
+ values.reduce(Flt::DecNum(0)) do |maximum, value|
117
+ peak = value if value > peak
118
+ [maximum, (peak - value) / peak].max
119
+ end
120
+ end
121
+
20
122
  # Computing Coefficient of variation
21
123
  #
22
124
  # @param sd standard deviation
@@ -4,17 +4,13 @@ require_relative 'validation'
4
4
 
5
5
  module Finrb
6
6
  # the Transaction class provides a general interface for working with individual cash flows.
7
- # @api public
8
7
  class Transaction
9
8
  # @return [Flt::DecNum] the cash value of the transaction
10
- # @api public
11
9
  attr_reader :amount
12
10
  # @return [Integer] the period number of the transaction
13
11
  # @note this attribute is mainly used in the case of mortgage amortization with no dates
14
- # @api public
15
12
  attr_reader :period
16
13
  # @return [Date] the date of the transaction
17
- # @api public
18
14
  attr_reader :date
19
15
 
20
16
  # create a new Transaction
@@ -26,7 +22,6 @@ module Finrb
26
22
  # t = Transaction.new(400)
27
23
  # @example a transaction with a period number
28
24
  # t = Transaction.new(400, :period => 3)
29
- # @api public
30
25
  def initialize(amount, opts = {})
31
26
  raise(ArgumentError, 'options must be a Hash.') unless opts.is_a?(Hash)
32
27
  raise(ArgumentError, 'options may only contain date and period.') unless (opts.keys - %i[date period]).empty?
@@ -47,7 +42,6 @@ module Finrb
47
42
  # t = Transaction.new(500)
48
43
  # t.amount = 750
49
44
  # t.amount #=> 750
50
- # @api public
51
45
  def amount=(value)
52
46
  @amount = Validation.decimal(value, name: 'amount')
53
47
  end
@@ -71,7 +65,6 @@ module Finrb
71
65
  # t = Transaction.new(500)
72
66
  # t.amount = 750
73
67
  # t.difference #=> Flt::DecNum('250')
74
- # @api public
75
68
  def difference
76
69
  @amount - @original
77
70
  end
@@ -82,12 +75,10 @@ module Finrb
82
75
  # int = Interest.new(500)
83
76
  # pmt.interest? #=> False
84
77
  # int.interest? #=> True
85
- # @api public
86
78
  def interest?
87
79
  instance_of?(Interest)
88
80
  end
89
81
 
90
- # @api public
91
82
  def inspect
92
83
  "Transaction(#{@amount.round(2)}, date: #{@date})"
93
84
  end
@@ -99,7 +90,6 @@ module Finrb
99
90
  # pmt = Payment.new(-500)
100
91
  # pmt.modify { |t| t.amount-100 }
101
92
  # pmt.amount #=> -600
102
- # @api public
103
93
  def modify
104
94
  self.amount = yield(self)
105
95
  end
@@ -116,7 +106,6 @@ module Finrb
116
106
  # int = Interest.new(500)
117
107
  # pmt.payment? #=> True
118
108
  # int.payment? #=> False
119
- # @api public
120
109
  def payment?
121
110
  instance_of?(Payment)
122
111
  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.0.1'
4
+ VERSION = '1.1.0'
5
5
  public_constant :VERSION
6
6
  end
data/sig/finrb.rbs CHANGED
@@ -94,14 +94,40 @@ module Finrb
94
94
  end
95
95
 
96
96
  class Amortization
97
- def self.payment: (number, number, Integer) -> decimal
97
+ class Entry
98
+ def initialize: (period: Integer, opening_balance: number, payment: number, interest: number, principal: number, additional_payment: number, balloon_payment: number, interest_only: bool, closing_balance: number) -> void
99
+ def period: () -> Integer
100
+ def opening_balance: () -> decimal
101
+ def payment: () -> decimal
102
+ def interest: () -> decimal
103
+ def principal: () -> decimal
104
+ def additional_payment: () -> decimal
105
+ def balloon_payment: () -> decimal
106
+ def interest_only: () -> bool
107
+ def interest_only?: () -> bool
108
+ def closing_balance: () -> decimal
109
+ def ==: (untyped) -> bool
110
+ def eql?: (untyped) -> bool
111
+ def hash: () -> Integer
112
+ def to_h: () -> Hash[Symbol, Integer | decimal]
113
+ end
114
+
115
+ def self.payment: (number, number, Integer, ?balloon: number) -> decimal
98
116
 
99
- def initialize: (number, *Rate) ?{ (Transaction) -> number } -> void
117
+ def initialize: (number, *Rate, ?balloon: number, ?interest_only_periods: Integer, ?origination_fee: number, ?finance_origination_fee: bool) ?{ (Transaction) -> number } -> void
100
118
  def ==: (untyped) -> bool
101
119
  def balance: () -> decimal
120
+ def balloon: () -> decimal
121
+ def amount_financed: () -> decimal
122
+ def net_proceeds: () -> decimal
123
+ def origination_fee: () -> decimal
124
+ def finance_origination_fee: () -> bool
125
+ def finance_origination_fee?: () -> bool
126
+ def interest_only_periods: () -> Integer
102
127
  def payment: () -> decimal?
103
128
  def principal: () -> decimal
104
129
  def rates: () -> Array[Rate]
130
+ def schedule: () -> Array[Entry]
105
131
  def additional_payments: () -> Array[decimal]
106
132
  def duration: () -> Integer
107
133
  def inspect: () -> String
@@ -111,11 +137,13 @@ module Finrb
111
137
 
112
138
  module Cashflow
113
139
  def self.irr: (Enumerable[number], ?number) -> decimal
140
+ def self.mirr: (Enumerable[number], finance_rate: number, reinvestment_rate: number) -> decimal
114
141
  def self.npv: (Enumerable[number], number) -> decimal
115
142
  def self.xirr: (Enumerable[Transaction], ?number) -> Rate
116
143
  def self.xnpv: (Enumerable[Transaction], number) -> decimal
117
144
 
118
145
  def irr: (?number) -> decimal
146
+ def mirr: (finance_rate: number, reinvestment_rate: number) -> decimal
119
147
  def npv: (number) -> decimal
120
148
  def xirr: (?number) -> Rate
121
149
  def xnpv: (number) -> decimal
@@ -163,15 +191,22 @@ module Finrb
163
191
  end
164
192
 
165
193
  module Returns
194
+ def self.annualize_return: (rate: number, periods_per_year: Integer) -> decimal
195
+ def self.annualize_volatility: (volatility: number, periods_per_year: Integer) -> decimal
196
+ def self.cagr: (beginning_value: number, ending_value: number, periods: Integer) -> decimal
166
197
  def self.coefficient_variation: (sd: number, avg: number) -> decimal
167
198
  def self.geometric_mean: (r: number | numbers) -> decimal
168
199
  def self.harmonic_mean: (p: number | numbers) -> decimal
169
200
  def self.hpr: (ev: number, bv: number, ?cfr: number) -> decimal
201
+ def self.downside_deviation: (returns: number | numbers, ?target: number) -> decimal
202
+ def self.max_drawdown: (values: number | numbers) -> decimal
170
203
  def self.sampling_error: (sm: number, mu: number) -> decimal
171
204
  def self.sf_ratio: (rp: number, rl: number, sd: number) -> decimal
172
205
  def self.sharpe_ratio: (rp: number, rf: number, sd: number) -> decimal
206
+ def self.sortino_ratio: (returns: number | numbers, ?target: number, ?periods_per_year: Integer?) -> decimal
173
207
  def self.twrr: (ev: number | numbers, bv: number | numbers, cfr: number | numbers) -> decimal
174
208
  def self.wpr: (r: number | numbers, w: number | numbers) -> decimal
209
+ def self.volatility: (returns: number | numbers, ?sample: bool) -> decimal
175
210
  end
176
211
 
177
212
  module Yields
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: finrb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nadir Cohen