xirr 0.7.1 → 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.
@@ -1,18 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Xirr
4
- # Methods that will be included in Cashflow to calculate XIRR
4
+ # Bisection solver: repeatedly halves a bracket that straddles the root. It
5
+ # always converges on a bracketed flow but only linearly, so it is slower than
6
+ # {RtSafe}, which is the default. Kept for `xirr(method: :bisection)`.
5
7
  class Bisection
6
8
  include Base
7
9
 
8
10
  # Calculates yearly Internal Rate of Return
9
- # @return [BigDecimal]
11
+ # @return [Float]
10
12
  # @param midpoint [Float]
11
13
  # An initial guess rate will override the {Cashflow#irr_guess}
12
14
  def xirr(midpoint, options)
13
15
  # Initial values
14
- left = [BigDecimal(-0.99999999, Xirr.config.precision), cf.irr_guess].min
15
- right = [BigDecimal(9.99999999, Xirr.config.precision), cf.irr_guess + 1].max
16
+ left = [-0.99999999, cf.irr_guess].min
17
+ right = [9.99999999, cf.irr_guess + 1].max
16
18
  @original_right = right
17
19
  midpoint ||= cf.irr_guess
18
20
 
@@ -23,16 +25,16 @@ module Xirr
23
25
 
24
26
  private
25
27
 
26
- # @param midpoint [BigDecimal]
28
+ # @param midpoint [Float]
27
29
  # @return [Boolean]
28
30
  # Checks if result is the right limit.
29
31
  def right_limit_reached?(midpoint)
30
32
  (@original_right - midpoint).abs < Xirr.config.eps
31
33
  end
32
34
 
33
- # @param left [BigDecimal]
34
- # @param midpoint [BigDecimal]
35
- # @param right [BigDecimal]
35
+ # @param left [Float]
36
+ # @param midpoint [Float]
37
+ # @param right [Float]
36
38
  # @return [Array]
37
39
  # Calculates the Bisections
38
40
  def bisection(left, midpoint, right)
@@ -57,11 +59,13 @@ module Xirr
57
59
 
58
60
  def get_answer(midpoint, options, runs)
59
61
  if runs >= options[:iteration_limit]
60
- if options[:raise_exception]
61
- raise ArgumentError, "Did not converge after #{runs} tries."
62
- end
62
+ raise ArgumentError, "Did not converge after #{runs} tries." if options[:raise_exception]
63
+
64
+ nil
63
65
  else
64
- midpoint.round Xirr.config.precision
66
+ answer = midpoint.round(Xirr.config.precision)
67
+ # A midpoint parked at the -100% floor means no root was bracketed.
68
+ answer <= -1 ? nil : answer
65
69
  end
66
70
  end
67
71
 
data/lib/xirr/bonds.rb ADDED
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Xirr
4
+ # Fixed income: pricing a bond, solving for its yield, and the standard risk
5
+ # metrics (Macaulay and modified duration, convexity).
6
+ #
7
+ # A bond pays a coupon of +coupon_rate+ a year, split across +freq+ payments
8
+ # (semiannual by default), and returns its +face+ value at maturity, +years+
9
+ # from now. Rates are quoted per year; +ytm+ is the yield to maturity.
10
+ # Settlement is assumed to fall on a coupon date, so prices are clean and the
11
+ # number of coupon periods is whole.
12
+ #
13
+ # The risk metrics don't depend on the face value (it cancels out), so they
14
+ # omit it and lead with +coupon_rate+, unlike {price} and {ytm}.
15
+ module Bonds
16
+ module_function
17
+
18
+ # Price of a bond: the present value of its coupons and face value discounted
19
+ # at the yield +ytm+. Prices at par when the coupon rate equals the yield.
20
+ # @return [Float]
21
+ def price(face, coupon_rate, ytm, years, freq = 2, precision: Xirr.config.precision)
22
+ n = periods(years, freq)
23
+ value = RtSafe.present_value(bond_flows(face, coupon_rate, n, freq), ytm.to_f / freq)
24
+ round_value(value, precision)
25
+ end
26
+
27
+ # Yield to maturity: the annual yield that discounts a bond's coupons and
28
+ # face value back to +price+. The inverse of {price}; reuses the {RtSafe}
29
+ # solver.
30
+ # @return [Float]
31
+ def ytm(face, coupon_rate, price, years, freq = 2, guess: 0.05, precision: Xirr.config.precision)
32
+ n = periods(years, freq)
33
+ flows = [[0.0, -price * 1.0]] + bond_flows(face, coupon_rate, n, freq)
34
+ periodic = RtSafe.find(flows, guess: guess)
35
+ raise ArgumentError, 'ytm did not converge' if periodic.nil?
36
+
37
+ round_value(periodic * freq, precision)
38
+ end
39
+
40
+ # Macaulay duration — the present-value-weighted average time, in years,
41
+ # until a bond's cash flows are received.
42
+ # @return [Float]
43
+ def duration(coupon_rate, ytm, years, freq = 2, precision: Xirr.config.precision)
44
+ with_metric(coupon_rate, years, freq, precision) do |flows|
45
+ macaulay(flows, ytm.to_f / freq, freq)
46
+ end
47
+ end
48
+
49
+ # Modified duration — Macaulay duration divided by +1 + ytm/freq+. Estimates
50
+ # the percentage price change for a small change in yield.
51
+ # @return [Float]
52
+ def modified_duration(coupon_rate, ytm, years, freq = 2, precision: Xirr.config.precision)
53
+ with_metric(coupon_rate, years, freq, precision) do |flows|
54
+ macaulay(flows, ytm.to_f / freq, freq) / (1 + ytm.to_f / freq)
55
+ end
56
+ end
57
+
58
+ # Convexity, in years² — the second-order sensitivity of a bond's price to
59
+ # yield. Pairs with modified duration to refine a price-change estimate.
60
+ # @return [Float]
61
+ def convexity(coupon_rate, ytm, years, freq = 2, precision: Xirr.config.precision)
62
+ with_metric(coupon_rate, years, freq, precision) do |flows|
63
+ convexity_value(flows, ytm.to_f / freq, freq)
64
+ end
65
+ end
66
+
67
+ # --- helpers ------------------------------------------------------------
68
+
69
+ def round_value(value, precision)
70
+ value.round(precision) + 0.0
71
+ end
72
+
73
+ # Validate periods, build unit-face flows, apply +block+, and round.
74
+ def with_metric(coupon_rate, years, freq, precision)
75
+ flows = bond_flows(1, coupon_rate, periods(years, freq), freq)
76
+ round_value(yield(flows), precision)
77
+ end
78
+
79
+ # Whole, positive coupon-period count.
80
+ def periods(years, freq)
81
+ n = years * freq
82
+ t = n.to_i
83
+ raise ArgumentError, 'years * freq must be a positive whole number' unless freq > 0 && n == t && t.positive?
84
+
85
+ t
86
+ end
87
+
88
+ # Coupon + redemption flows on +face+ as [[period, amount]], k = 1..n; the
89
+ # face value is added onto the final coupon.
90
+ def bond_flows(face, coupon_rate, n, freq)
91
+ coupon = face * coupon_rate / freq
92
+ (1..n).map do |k|
93
+ amount = k == n ? coupon + face : coupon
94
+ [k * 1.0, amount * 1.0]
95
+ end
96
+ end
97
+
98
+ def weighted_pv(flows, rate)
99
+ flows.map { |k, cf| [k, cf / (1 + rate)**k] }
100
+ end
101
+
102
+ # Macaulay duration in years: PV-weighted average period, divided by freq.
103
+ def macaulay(flows, rate, freq)
104
+ weighted = weighted_pv(flows, rate)
105
+ price = weighted.sum { |_k, pv| pv }
106
+ weighted.sum { |k, pv| k * pv } / price / freq
107
+ end
108
+
109
+ # Convexity in years²: the k·(k+1)-weighted PV sum, annualized by freq².
110
+ def convexity_value(flows, rate, freq)
111
+ weighted = weighted_pv(flows, rate)
112
+ price = weighted.sum { |_k, pv| pv }
113
+ num = weighted.sum { |k, pv| k * (k + 1) * pv }
114
+ num / price / (1 + rate)**2 / freq**2
115
+ end
116
+
117
+ private_class_method :round_value, :with_metric, :periods, :bond_flows,
118
+ :weighted_pv, :macaulay, :convexity_value
119
+ end
120
+ end
data/lib/xirr/brent.rb ADDED
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Xirr
4
+ # Brent's method: a derivative-free root finder combining inverse quadratic
5
+ # interpolation, the secant method, and bisection. It reuses {RtSafe}'s
6
+ # bracketing, so it is as robust as the default solver but never evaluates the
7
+ # NPV derivative — each iteration is cheaper, though it needs more of them.
8
+ #
9
+ # In practice it roughly ties {RtSafe}; it is offered for very large cashflows,
10
+ # where the cheaper per-iteration cost can win. Select it with
11
+ # `xirr(method: :brent)`. Unlike the Newton-based solvers it ignores the initial
12
+ # guess — it works from the bracket.
13
+ class Brent
14
+ include Base
15
+
16
+ # @param guess [Float, nil] ignored; Brent brackets the root itself
17
+ # @param options [Hash] reads +:iteration_limit+
18
+ # @return [Float, nil]
19
+ def xirr(_guess, options)
20
+ limit = (options && options[:iteration_limit]) || Xirr.config.iteration_limit
21
+ Brent.find(flows, iteration_limit: limit)
22
+ end
23
+
24
+ # Pure solver over normalized +[time, amount]+ flows.
25
+ # @param flows [Array<Array(Float, Float)>]
26
+ # @return [Float, nil]
27
+ def self.find(flows, tolerance: Xirr.config.eps, iteration_limit: Xirr.config.iteration_limit, precision: Xirr.config.precision)
28
+ rate = zbrent(flows, tolerance.to_f, iteration_limit)
29
+ return nil if rate.nil? || rate.nan? || rate.infinite?
30
+
31
+ rounded = rate.round(precision)
32
+ rounded <= -1.0 ? nil : rounded
33
+ rescue FloatDomainError, Math::DomainError
34
+ nil
35
+ end
36
+
37
+ # Bracket a sign change (reusing RtSafe), then iterate Brent's method within
38
+ # it. Returns the rate, or nil if it can't bracket or converge.
39
+ def self.zbrent(flows, tol, iteration_limit)
40
+ low = RtSafe.safe_low(flows)
41
+ f_low = RtSafe.present_value(flows, low)
42
+ bounds = RtSafe.bracket(flows, low, f_low, 1.0)
43
+ return nil if bounds.nil?
44
+
45
+ a, b = bounds
46
+ fa = f_low # a == low
47
+ fb = RtSafe.present_value(flows, b)
48
+ c = a
49
+ fc = fa
50
+ d = e = b - a
51
+
52
+ iteration_limit.times do
53
+ # Keep c as the contrapoint — opposite sign to b, so [b, c] brackets.
54
+ if (fb.positive? && fc.positive?) || (fb.negative? && fc.negative?)
55
+ c = a
56
+ fc = fa
57
+ d = e = b - a
58
+ end
59
+ # Ensure b is the better estimate.
60
+ if fc.abs < fb.abs
61
+ a = b
62
+ b = c
63
+ c = a
64
+ fa = fb
65
+ fb = fc
66
+ fc = fa
67
+ end
68
+
69
+ tol1 = 2.0 * Float::EPSILON * b.abs + 0.5 * tol
70
+ xm = 0.5 * (c - b)
71
+ return b if xm.abs <= tol1 || fb.zero?
72
+
73
+ if e.abs >= tol1 && fa.abs > fb.abs
74
+ s = fb / fa
75
+ if a == c
76
+ # Secant step.
77
+ p = 2.0 * xm * s
78
+ q = 1.0 - s
79
+ else
80
+ # Inverse quadratic interpolation.
81
+ q = fa / fc
82
+ r = fb / fc
83
+ p = s * (2.0 * xm * q * (q - r) - (b - a) * (r - 1.0))
84
+ q = (q - 1.0) * (r - 1.0) * (s - 1.0)
85
+ end
86
+ q = -q if p.positive?
87
+ p = p.abs
88
+ min1 = 3.0 * xm * q - (tol1 * q).abs
89
+ min2 = (e * q).abs
90
+ if 2.0 * p < (min1 < min2 ? min1 : min2)
91
+ e = d
92
+ d = p / q # accept interpolation
93
+ else
94
+ d = e = xm # fall back to bisection
95
+ end
96
+ else
97
+ d = e = xm # bounds decreasing too slowly; bisect
98
+ end
99
+
100
+ a = b
101
+ fa = fb
102
+ b += d.abs > tol1 ? d : (xm.positive? ? tol1 : -tol1)
103
+ fb = RtSafe.present_value(flows, b)
104
+ end
105
+ nil
106
+ end
107
+ end
108
+ end
data/lib/xirr/cashflow.rb CHANGED
@@ -45,40 +45,109 @@ module Xirr
45
45
  @max_date ||= map(&:date).max
46
46
  end
47
47
 
48
- # Calculates a simple IRR guess based on period of investment and multiples.
48
+ # A rough starting rate for the solver: the cash-on-cash multiple annualized
49
+ # over the investment horizon, +multiple^(1 / years) - 1+.
50
+ #
51
+ # It falls back to 0.0 whenever that estimate is undefined or unusable — an
52
+ # invalid or empty cashflow, a horizon of zero, a non-positive multiple, or a
53
+ # rate at or below -100% (which the solver can't start from). The default
54
+ # +rtsafe+ solver does not depend on this being accurate; it only needs a
55
+ # finite rate above -1.
49
56
  # @return [Float]
50
57
  def irr_guess
51
- return @irr_guess = 0.0 if periods_of_investment.zero?
52
- return @irr_guess = 0.0 if multiple <= 0
53
-
54
- @irr_guess = valid? ? ((multiple**(1.0 / periods_of_investment)) - 1).round(3) : 0.0
55
- @irr_guess.infinite? ? 0.0 : @irr_guess
58
+ return @irr_guess = 0.0 unless valid?
59
+ return @irr_guess = 0.0 if periods_of_investment.zero? || multiple <= 0
60
+
61
+ guess = ((multiple**(1.0 / periods_of_investment)) - 1).round(3)
62
+ @irr_guess = (guess.nan? || guess.infinite? || guess <= -1) ? 0.0 : guess
56
63
  end
57
64
 
58
65
  # @param guess [Float]
59
66
  # @param method [Symbol]
60
- # @return [Float]
67
+ # @return [Float] the rate, or +Xirr.config.replace_for_nil+ when it can't converge
61
68
  def xirr(guess: nil, method: nil, ** options)
62
69
  method, options = process_options(method, options)
63
70
  if invalid?
64
71
  raise ArgumentError, invalid_message if options[:raise_exception]
65
- BigDecimal(0, Xirr.config.precision)
66
- else
67
- xirr = choose_(method).send :xirr, guess, options
68
- xirr = choose_(other_calculation_method(method)).send(:xirr, guess, options) if (xirr.nil? || xirr.nan?) && fallback
69
- xirr || Xirr.config.replace_for_nil
72
+ return Xirr.config.replace_for_nil
70
73
  end
74
+
75
+ result = choose_(method).send(:xirr, guess, options)
76
+ # rtsafe already combines a Newton step with a bisection safeguard, so it is
77
+ # the fallback for the fragile solvers; when it is itself the primary there
78
+ # is nothing better to try, and a failure means the flow has no rate.
79
+ if unconverged?(result) && fallback && method != :rtsafe && method != :rtsafe_c
80
+ result = choose_(:rtsafe).send(:xirr, guess, options)
81
+ end
82
+ if unconverged?(result)
83
+ raise ArgumentError, 'XIRR did not converge' if options[:raise_exception]
84
+ return Xirr.config.replace_for_nil
85
+ end
86
+ result
87
+ ensure
88
+ # A per-call +period:+ must not leak into later #xnpv / #mirr calls.
89
+ @temporary_period = nil
90
+ end
91
+
92
+ # Same as {#xirr}, but raises +ArgumentError+ when the cashflow is invalid or
93
+ # the rate can't be found, instead of returning +Xirr.config.replace_for_nil+.
94
+ # @return [Float]
95
+ def xirr!(guess: nil, method: nil, ** options)
96
+ xirr(guess: guess, method: method, **options.merge(raise_exception: true))
97
+ end
98
+
99
+ # Net present value of the dated flows discounted at +rate+ on the same
100
+ # Actual/period basis {#xirr} uses. Roughly zero when +rate+ is the XIRR.
101
+ # @param rate [Float]
102
+ # @return [Float]
103
+ def xnpv(rate)
104
+ inject(0.0) { |sum, t| sum + t.amount / (1.0 + rate) ** ((t.date - min_date) / period) }
105
+ end
106
+
107
+ # Modified IRR of the dated flows. Positive flows are assumed reinvested at
108
+ # +reinvest_rate+, negative flows financed at +finance_rate+. Unlike XIRR it
109
+ # has a closed form and a single answer.
110
+ # @param finance_rate [Float]
111
+ # @param reinvest_rate [Float]
112
+ # @return [Float]
113
+ def mirr(finance_rate, reinvest_rate)
114
+ raise ArgumentError, invalid_message if invalid?
115
+ years = periods_of_investment
116
+ raise ArgumentError, 'Flows span no time' if years.zero?
117
+
118
+ future_of_inflows = 0.0
119
+ present_of_outflows = 0.0
120
+ each do |t|
121
+ if t.amount > 0
122
+ future_of_inflows += t.amount * (1.0 + reinvest_rate) ** ((max_date - t.date) / period)
123
+ elsif t.amount < 0
124
+ present_of_outflows += t.amount / (1.0 + finance_rate) ** ((t.date - min_date) / period)
125
+ end
126
+ end
127
+
128
+ ((future_of_inflows / -present_of_outflows) ** (1.0 / years) - 1).round(Xirr.config.precision)
71
129
  end
72
130
 
73
131
  def process_options(method, options)
74
132
  @temporary_period = options[:period]
75
- options[:raise_exception] ||= @options[:raise_exception] || Xirr.config.raise_exception
76
- options[:iteration_limit] ||= @options[:iteration_limit] || Xirr.config.iteration_limit
133
+ options[:raise_exception] = resolve_option(options, :raise_exception)
134
+ options[:iteration_limit] = resolve_option(options, :iteration_limit)
77
135
  return switch_fallback(method), options
78
136
  end
79
137
 
80
- # If method is defined it will turn off fallback
81
- # it return either the provided method or the system default
138
+ # Resolve an option in precedence order: this call, then the cashflow's own
139
+ # options, then the global config. Reads +key?+ so an explicit +false+ or +0+
140
+ # is honored rather than overwritten.
141
+ # @return [Object]
142
+ def resolve_option(options, key)
143
+ return options[key] if options.key?(key)
144
+ return @options[key] if @options.key?(key)
145
+
146
+ Xirr.config.public_send(key)
147
+ end
148
+
149
+ # Providing a method turns off fallback; otherwise use the configured default
150
+ # with fallback on. Returns the method to run.
82
151
  # @param method [Symbol]
83
152
  # @return [Symbol]
84
153
  def switch_fallback(method)
@@ -91,15 +160,14 @@ module Xirr
91
160
  end
92
161
  end
93
162
 
94
- def other_calculation_method(method)
95
- method == :newton_method ? :bisection : :newton_method
96
- end
163
+ private :process_options, :resolve_option, :switch_fallback
97
164
 
165
+ # A copy of the cashflow with same-date transactions merged into one.
166
+ # @return [Cashflow]
98
167
  def compact_cf
99
- # self
100
168
  compact = Hash.new 0
101
169
  each { |flow| compact[flow.date] += flow.amount }
102
- Cashflow.new flow: compact.map { |key, value| Transaction.new(value, date: key) }, options: options, period: period
170
+ Cashflow.new(flow: compact.map { |date, amount| Transaction.new(amount, date: date) }, period: period, **options)
103
171
  end
104
172
 
105
173
  # First investment date
@@ -122,6 +190,9 @@ module Xirr
122
190
  def <<(arg)
123
191
  super arg
124
192
  sort! { |x, y| x.date <=> y.date }
193
+ # Adding a transaction can change the date range and leading sign, so drop
194
+ # the values memoized from the old contents.
195
+ @min_date = @max_date = @first_transaction_direction = nil
125
196
  self
126
197
  end
127
198
 
@@ -132,22 +203,37 @@ module Xirr
132
203
  # @return [Class]
133
204
  def choose_(method)
134
205
  case method
206
+ when :rtsafe
207
+ RtSafe.new compact_cf
208
+ when :brent
209
+ Brent.new compact_cf
210
+ when :rtsafe_c
211
+ unless Xirr::NATIVE
212
+ raise ArgumentError, 'the native :rtsafe_c extension is not compiled; ' \
213
+ 'reinstall the gem with a C toolchain or run `rake compile`'
214
+ end
215
+ RtSafeC.new compact_cf
135
216
  when :bisection
136
217
  Bisection.new compact_cf
137
218
  when :newton_method
138
219
  NewtonMethod.new compact_cf
139
220
  else
140
- raise ArgumentError, "There is no method called #{method} "
221
+ raise ArgumentError, "unknown method #{method.inspect}; use :rtsafe, :rtsafe_c, :brent, :bisection, or :newton_method"
141
222
  end
142
223
  end
143
224
 
225
+ # A solver result that means "no rate found": nil, or a NaN.
226
+ # @return [Boolean]
227
+ def unconverged?(value)
228
+ value.nil? || (value.respond_to?(:nan?) && value.nan?)
229
+ end
230
+
144
231
  # @api private
145
232
  # Sorts the {Cashflow} by date ascending
146
233
  # and finds the signal of the first transaction.
147
234
  # This implies the first transaction is a disbursement
148
235
  # @return [Integer]
149
236
  def first_transaction_direction
150
- # self.sort! { |x, y| x.date <=> y.date }
151
237
  @first_transaction_direction ||= first.amount / first.amount.abs
152
238
  end
153
239
 
data/lib/xirr/config.rb CHANGED
@@ -1,22 +1,25 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Xirr
2
4
  include ActiveSupport::Configurable
3
5
 
4
- # Sets as constants all the entries in the Hash Default values
6
+ # Default configuration. Each entry becomes both a config setting
7
+ # (+Xirr.config.eps+) and a frozen constant of the same name upcased
8
+ # (+Xirr::EPS+); the constant keeps the original default even after the setting
9
+ # is reconfigured.
5
10
  default_values = {
6
11
  eps: '1.0e-6'.to_f,
7
12
  period: 365.0,
8
13
  iteration_limit: 50,
9
14
  precision: 6,
10
- default_method: :newton_method,
15
+ default_method: :rtsafe,
11
16
  fallback: true,
12
17
  replace_for_nil: 0.0,
13
- compact: true,
14
18
  raise_exception: false
15
19
  }
16
20
 
17
- # Iterates though default values and sets in config
18
21
  default_values.each do |key, value|
19
- self.config.send("#{key.to_sym}=", value)
22
+ config.public_send("#{key}=", value)
20
23
  const_set key.to_s.upcase.to_sym, value
21
24
  end
22
25
  end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Xirr
4
+ # Writing an asset down from its +cost+ to its +salvage+ value over its +life+.
5
+ # {sln} spreads the loss evenly; {syd}, {ddb}, and {db} are accelerated methods
6
+ # that depreciate more early on and return the amount for a single +period+
7
+ # (counting from 1).
8
+ module Depreciation
9
+ module_function
10
+
11
+ # Straight-line depreciation: the equal amount the asset is written down by
12
+ # each period.
13
+ # @return [Float]
14
+ def sln(cost, salvage, life)
15
+ raise ArgumentError, 'life must be non-zero' if life.zero?
16
+
17
+ (cost - salvage) / life * 1.0
18
+ end
19
+
20
+ # Sum-of-years'-digits depreciation for a single +period+ — an accelerated
21
+ # method charging more in the early periods.
22
+ # @return [Float]
23
+ def syd(cost, salvage, life, period)
24
+ raise ArgumentError, 'undefined' if life <= 0 || period < 1 || period > life
25
+
26
+ (cost - salvage) * (life - period + 1) * 2.0 / (life * (life + 1))
27
+ end
28
+
29
+ # Double-declining-balance depreciation for a single +period+: each period
30
+ # takes +factor/life+ of the remaining book value (never below +salvage+).
31
+ # +factor+ defaults to 2 (the usual double-declining rate).
32
+ # @return [Float]
33
+ def ddb(cost, salvage, life, period, factor = 2)
34
+ n = period.to_i
35
+ unless life.positive? && factor.positive? && period == n && n >= 1 && n <= life
36
+ raise ArgumentError, 'undefined'
37
+ end
38
+
39
+ declining_balance(cost, salvage, factor.to_f / life, n)
40
+ end
41
+
42
+ # Fixed-declining-balance depreciation for a single +period+. Like {ddb}, but
43
+ # the rate is derived from +cost+, +salvage+, and +life+ (rounded to three
44
+ # places, as spreadsheets do). +month+ is how many months the asset was in
45
+ # service during its first year (default 12); a shorter first year spills the
46
+ # remainder into an extra final period.
47
+ # @return [Float]
48
+ def db(cost, salvage, life, period, month = 12)
49
+ n = period.to_i
50
+ unless cost.positive? && salvage >= 0 && life.positive? && month >= 1 && month <= 12 &&
51
+ period == n && n >= 1 && n <= life + 1
52
+ raise ArgumentError, 'undefined'
53
+ end
54
+
55
+ fixed_declining(cost, salvage, life, n, month)
56
+ end
57
+
58
+ # --- helpers ------------------------------------------------------------
59
+
60
+ # Walk periods 1..period carrying accumulated depreciation; return the amount
61
+ # for the final period. Depreciation stops at the salvage floor.
62
+ def declining_balance(cost, salvage, rate, period)
63
+ accumulated = 0.0
64
+ dep = 0.0
65
+ (1..period).each do
66
+ book = cost - accumulated
67
+ dep = [[book * rate, book - salvage].min, 0.0].max
68
+ accumulated += dep
69
+ end
70
+ dep
71
+ end
72
+
73
+ def fixed_declining(cost, salvage, life, period, month)
74
+ rate = (1 - (salvage.to_f / cost)**(1.0 / life)).round(3)
75
+ accumulated = 0.0
76
+ dep = 0.0
77
+ (1..period).each do |p|
78
+ dep = db_period(cost, accumulated, rate, life, month, p)
79
+ accumulated += dep
80
+ end
81
+ dep
82
+ end
83
+
84
+ def db_period(cost, accumulated, rate, life, month, period)
85
+ return cost * rate * month / 12.0 if period == 1
86
+
87
+ if period <= life
88
+ (cost - accumulated) * rate
89
+ else
90
+ # The partial last period when the first year was shorter than 12 months.
91
+ (cost - accumulated) * rate * (12 - month) / 12.0
92
+ end
93
+ end
94
+
95
+ private_class_method :declining_balance, :fixed_declining, :db_period
96
+ end
97
+ end