finrb 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +25 -0
- data/CONTRIBUTING.md +89 -0
- data/README.md +4 -2
- data/SECURITY.md +41 -0
- data/lib/finrb/accounting.rb +93 -74
- data/lib/finrb/amortization.rb +6 -10
- data/lib/finrb/cashflows.rb +4 -7
- data/lib/finrb/config.rb +5 -5
- data/lib/finrb/rates.rb +3 -7
- data/lib/finrb/ratios.rb +41 -40
- data/lib/finrb/returns.rb +49 -52
- data/lib/finrb/tvm.rb +154 -52
- data/lib/finrb/validation.rb +42 -0
- data/lib/finrb/version.rb +1 -1
- data/lib/finrb/yields.rb +42 -31
- data/sig/finrb.rbs +2 -2
- metadata +17 -1
data/lib/finrb/ratios.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
|
# Financial-statement, leverage, and per-share ratios.
|
|
@@ -25,9 +26,9 @@ module Finrb
|
|
|
25
26
|
# @example
|
|
26
27
|
# Finrb::Ratios.cash_ratio(cash=3000,ms=2000,cl=2000)
|
|
27
28
|
def self.cash_ratio(cash:, ms:, cl:)
|
|
28
|
-
cash =
|
|
29
|
-
ms =
|
|
30
|
-
cl =
|
|
29
|
+
cash = Validation.decimal(cash, name: 'cash')
|
|
30
|
+
ms = Validation.decimal(ms, name: 'marketable securities')
|
|
31
|
+
cl = Validation.non_zero_decimal(cl, name: 'current liabilities', error: DomainError)
|
|
31
32
|
|
|
32
33
|
((cash + ms) / cl)
|
|
33
34
|
end
|
|
@@ -39,8 +40,8 @@ module Finrb
|
|
|
39
40
|
# @example
|
|
40
41
|
# Finrb::Ratios.current_ratio(ca=8000,cl=2000)
|
|
41
42
|
def self.current_ratio(ca:, cl:)
|
|
42
|
-
ca =
|
|
43
|
-
cl =
|
|
43
|
+
ca = Validation.decimal(ca, name: 'current assets')
|
|
44
|
+
cl = Validation.non_zero_decimal(cl, name: 'current liabilities', error: DomainError)
|
|
44
45
|
|
|
45
46
|
(ca / cl)
|
|
46
47
|
end
|
|
@@ -52,8 +53,8 @@ module Finrb
|
|
|
52
53
|
# @example
|
|
53
54
|
# Finrb::Ratios.debt_ratio(td=6000,ta=20000)
|
|
54
55
|
def self.debt_ratio(td:, ta:)
|
|
55
|
-
td =
|
|
56
|
-
ta =
|
|
56
|
+
td = Validation.decimal(td, name: 'total debt')
|
|
57
|
+
ta = Validation.non_zero_decimal(ta, name: 'total assets', error: DomainError)
|
|
57
58
|
|
|
58
59
|
(td / ta)
|
|
59
60
|
end
|
|
@@ -81,15 +82,15 @@ module Finrb
|
|
|
81
82
|
# @example
|
|
82
83
|
# Finrb::Ratios.diluted_eps(ni=115600,pd=10000,cpd=10000,cdi=42000,tax=0.4,w=200000,cps=40000,cds=60000,iss=2500)
|
|
83
84
|
def self.diluted_eps(ni:, pd:, w:, cpd: 0, cdi: 0, tax: 0, cps: 0, cds: 0, iss: 0)
|
|
84
|
-
ni =
|
|
85
|
-
pd =
|
|
86
|
-
w =
|
|
87
|
-
cpd =
|
|
88
|
-
cdi =
|
|
89
|
-
tax =
|
|
90
|
-
cps =
|
|
91
|
-
cds =
|
|
92
|
-
iss =
|
|
85
|
+
ni = Validation.decimal(ni, name: 'net income')
|
|
86
|
+
pd = Validation.decimal(pd, name: 'preferred dividends')
|
|
87
|
+
w = Validation.positive_decimal(w, name: 'weighted average common shares', error: DomainError)
|
|
88
|
+
cpd = Validation.non_negative_decimal(cpd, name: 'convertible preferred dividends')
|
|
89
|
+
cdi = Validation.non_negative_decimal(cdi, name: 'convertible debt interest')
|
|
90
|
+
tax = Validation.decimal_between(tax, minimum: 0, maximum: 1, name: 'tax rate')
|
|
91
|
+
cps = Validation.non_negative_decimal(cps, name: 'convertible preferred shares')
|
|
92
|
+
cds = Validation.non_negative_decimal(cds, name: 'convertible debt shares')
|
|
93
|
+
iss = Validation.non_negative_decimal(iss, name: 'incremental option shares')
|
|
93
94
|
|
|
94
95
|
basic = (ni - pd) / w
|
|
95
96
|
diluted = (ni - pd + cpd + (cdi * (1 - tax))) / (w + cps + cds + iss)
|
|
@@ -105,9 +106,9 @@ module Finrb
|
|
|
105
106
|
# @example
|
|
106
107
|
# Finrb::Ratios.eps(ni=10000,pd=1000,w=11000)
|
|
107
108
|
def self.eps(ni:, pd:, w:)
|
|
108
|
-
ni =
|
|
109
|
-
pd =
|
|
110
|
-
w =
|
|
109
|
+
ni = Validation.decimal(ni, name: 'net income')
|
|
110
|
+
pd = Validation.decimal(pd, name: 'preferred dividends')
|
|
111
|
+
w = Validation.positive_decimal(w, name: 'weighted average common shares', error: DomainError)
|
|
111
112
|
|
|
112
113
|
((ni - pd) / w)
|
|
113
114
|
end
|
|
@@ -119,8 +120,8 @@ module Finrb
|
|
|
119
120
|
# @example
|
|
120
121
|
# Finrb::Ratios.financial_leverage(te=16000,ta=20000)
|
|
121
122
|
def self.financial_leverage(te:, ta:)
|
|
122
|
-
te =
|
|
123
|
-
ta =
|
|
123
|
+
te = Validation.non_zero_decimal(te, name: 'total equity', error: DomainError)
|
|
124
|
+
ta = Validation.decimal(ta, name: 'total assets')
|
|
124
125
|
|
|
125
126
|
(ta / te)
|
|
126
127
|
end
|
|
@@ -132,8 +133,8 @@ module Finrb
|
|
|
132
133
|
# @example
|
|
133
134
|
# Finrb::Ratios.gpm(gp=1000,rv=20000)
|
|
134
135
|
def self.gpm(gp:, rv:)
|
|
135
|
-
gp =
|
|
136
|
-
rv =
|
|
136
|
+
gp = Validation.decimal(gp, name: 'gross profit')
|
|
137
|
+
rv = Validation.non_zero_decimal(rv, name: 'revenue', error: DomainError)
|
|
137
138
|
|
|
138
139
|
(gp / rv)
|
|
139
140
|
end
|
|
@@ -146,14 +147,14 @@ module Finrb
|
|
|
146
147
|
# @example
|
|
147
148
|
# Finrb::Ratios.iss(amp=20,ep=15,n=10000)
|
|
148
149
|
def self.iss(amp:, ep:, n:)
|
|
149
|
-
amp =
|
|
150
|
-
ep =
|
|
151
|
-
n =
|
|
150
|
+
amp = Validation.positive_decimal(amp, name: 'average market price', error: DomainError)
|
|
151
|
+
ep = Validation.non_negative_decimal(ep, name: 'exercise price')
|
|
152
|
+
n = Validation.non_negative_decimal(n, name: 'option shares')
|
|
152
153
|
|
|
153
154
|
if amp > ep
|
|
154
155
|
((amp - ep) * n / amp)
|
|
155
156
|
else
|
|
156
|
-
raise(
|
|
157
|
+
raise(DomainError, 'Average market price must be greater than exercise price.')
|
|
157
158
|
end
|
|
158
159
|
end
|
|
159
160
|
|
|
@@ -164,8 +165,8 @@ module Finrb
|
|
|
164
165
|
# @example
|
|
165
166
|
# Finrb::Ratios.lt_d2e(ltd=8000,te=20000)
|
|
166
167
|
def self.lt_d2e(ltd:, te:)
|
|
167
|
-
ltd =
|
|
168
|
-
te =
|
|
168
|
+
ltd = Validation.decimal(ltd, name: 'long-term debt')
|
|
169
|
+
te = Validation.non_zero_decimal(te, name: 'total equity', error: DomainError)
|
|
169
170
|
|
|
170
171
|
(ltd / te)
|
|
171
172
|
end
|
|
@@ -177,8 +178,8 @@ module Finrb
|
|
|
177
178
|
# @example
|
|
178
179
|
# Finrb::Ratios.npm(ni=8000,rv=20000)
|
|
179
180
|
def self.npm(ni:, rv:)
|
|
180
|
-
ni =
|
|
181
|
-
rv =
|
|
181
|
+
ni = Validation.decimal(ni, name: 'net income')
|
|
182
|
+
rv = Validation.non_zero_decimal(rv, name: 'revenue', error: DomainError)
|
|
182
183
|
|
|
183
184
|
(ni / rv)
|
|
184
185
|
end
|
|
@@ -192,10 +193,10 @@ module Finrb
|
|
|
192
193
|
# @example
|
|
193
194
|
# Finrb::Ratios.quick_ratio(cash=3000,ms=2000,rc=1000,cl=2000)
|
|
194
195
|
def self.quick_ratio(cash:, ms:, rc:, cl:)
|
|
195
|
-
cash =
|
|
196
|
-
ms =
|
|
197
|
-
rc =
|
|
198
|
-
cl =
|
|
196
|
+
cash = Validation.decimal(cash, name: 'cash')
|
|
197
|
+
ms = Validation.decimal(ms, name: 'marketable securities')
|
|
198
|
+
rc = Validation.decimal(rc, name: 'receivables')
|
|
199
|
+
cl = Validation.non_zero_decimal(cl, name: 'current liabilities', error: DomainError)
|
|
199
200
|
|
|
200
201
|
((cash + ms + rc) / cl)
|
|
201
202
|
end
|
|
@@ -207,8 +208,8 @@ module Finrb
|
|
|
207
208
|
# @example
|
|
208
209
|
# Finrb::Ratios.total_d2e(td=6000,te=20000)
|
|
209
210
|
def self.total_d2e(td:, te:)
|
|
210
|
-
td =
|
|
211
|
-
te =
|
|
211
|
+
td = Validation.decimal(td, name: 'total debt')
|
|
212
|
+
te = Validation.non_zero_decimal(te, name: 'total equity', error: DomainError)
|
|
212
213
|
|
|
213
214
|
(td / te)
|
|
214
215
|
end
|
|
@@ -223,8 +224,8 @@ module Finrb
|
|
|
223
224
|
# @example
|
|
224
225
|
# s=[11000,4400,-3000];m=[12,9,4];Finrb::Ratios.was(ns=s,nm=m)
|
|
225
226
|
def self.was(ns:, nm:)
|
|
226
|
-
ns = wrap_array(ns).map { |value|
|
|
227
|
-
nm = wrap_array(nm).map { |value|
|
|
227
|
+
ns = wrap_array(ns).map { |value| Validation.decimal(value, name: 'share change') }
|
|
228
|
+
nm = wrap_array(nm).map { |value| Validation.decimal_between(value, minimum: 0, maximum: 12, name: 'months outstanding') }
|
|
228
229
|
|
|
229
230
|
m = ns.size
|
|
230
231
|
n = nm.size
|
|
@@ -234,7 +235,7 @@ module Finrb
|
|
|
234
235
|
sum += (ns[i] * nm[i])
|
|
235
236
|
end
|
|
236
237
|
else
|
|
237
|
-
raise(
|
|
238
|
+
raise(ArgumentError, 'Share changes and months outstanding must have equal lengths.')
|
|
238
239
|
end
|
|
239
240
|
sum /= 12
|
|
240
241
|
sum
|
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.
|
|
33
|
-
ending_value = Validation.
|
|
34
|
-
periods = Validation.positive_integer(periods, name: '
|
|
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.
|
|
53
|
-
periods_per_year = Validation.positive_integer(periods_per_year, name: '
|
|
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.
|
|
62
|
-
periods_per_year = Validation.positive_integer(periods_per_year, name: '
|
|
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: '
|
|
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 =
|
|
130
|
-
avg =
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
((
|
|
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
|
-
|
|
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) / (
|
|
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 =
|
|
166
|
-
bv =
|
|
167
|
-
cfr =
|
|
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 =
|
|
180
|
-
mu =
|
|
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 =
|
|
194
|
-
rl =
|
|
195
|
-
sd =
|
|
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 =
|
|
209
|
-
rf =
|
|
210
|
-
sd =
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
251
|
+
returns.zip(weights).sum { |rate, weight| rate * weight }
|
|
255
252
|
end
|
|
256
253
|
end
|
|
257
254
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
33
|
+
rate = periodic_rate(r)
|
|
34
|
+
periods = period_count(n)
|
|
35
|
+
payment_type(type)
|
|
23
36
|
|
|
24
|
-
fv_simple(r
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
41
|
-
cashflows =
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
(
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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 / ((
|
|
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
|
-
|
|
71
|
-
|
|
102
|
+
rate = periodic_rate(r)
|
|
103
|
+
periods = period_count(n)
|
|
104
|
+
payment_type(type)
|
|
72
105
|
|
|
73
|
-
pv_simple(r
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
(
|
|
126
|
+
(payment / (rate - growth)) * ((rate + 1)**payment_timing) * -1
|
|
89
127
|
end
|
|
90
128
|
|
|
91
129
|
def pv_simple(r:, n:, fv:)
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
pv_simple(r
|
|
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
|
-
|
|
105
|
-
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
179
|
+
def period_count(value)
|
|
180
|
+
Validation.non_negative_decimal(value, name: 'period count', error: DomainError)
|
|
113
181
|
end
|
|
114
|
-
private_class_method :
|
|
182
|
+
private_class_method :period_count
|
|
115
183
|
|
|
116
|
-
def
|
|
117
|
-
|
|
184
|
+
def positive_period_count(value)
|
|
185
|
+
Validation.positive_decimal(value, name: 'period count', error: DomainError)
|
|
118
186
|
end
|
|
119
|
-
private_class_method :
|
|
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
|
-
|
|
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 :
|
|
226
|
+
private_class_method :zero_rate_periods
|
|
125
227
|
end
|
|
126
228
|
end
|