Module: Finrb::Returns
- Defined in:
- lib/finrb/returns.rb,
sig/finrb.rbs
Overview
Investment return and risk-adjusted performance calculations.
Class Method Summary collapse
-
.annualize_return(rate:, periods_per_year:) ⇒ decimal
Compound a periodic return into an annual return.
-
.annualize_volatility(volatility:, periods_per_year:) ⇒ decimal
Scale periodic volatility by the square root of periods per year.
-
.cagr(beginning_value:, ending_value:, periods:) ⇒ Flt::DecNum
Compound annual growth rate over a positive number of periods.
-
.coefficient_variation(sd:, avg:) ⇒ decimal
Computing Coefficient of variation.
-
.downside_deviation(returns:, target: 0) ⇒ decimal
Root-mean-square return shortfall below a target return.
-
.geometric_mean(r:) ⇒ decimal
Geometric mean return.
-
.harmonic_mean(p:) ⇒ decimal
harmonic mean, average price.
-
.hpr(ev:, bv:, cfr: 0) ⇒ decimal
Computing HPR, the holding period return.
-
.max_drawdown(values:) ⇒ decimal
Largest peak-to-trough decline as a non-negative fraction.
-
.sampling_error(sm:, mu:) ⇒ decimal
Computing Sampling error.
-
.sf_ratio(rp:, rl:, sd:) ⇒ decimal
Computing Roy's safety-first ratio.
-
.sharpe_ratio(rp:, rf:, sd:) ⇒ decimal
Computing Sharpe Ratio.
-
.sortino_ratio(returns:, target: 0, periods_per_year: nil) ⇒ decimal
Sortino ratio using arithmetic mean excess return and downside deviation.
-
.twrr(ev:, bv:, cfr:) ⇒ decimal
Computing TWRR, the time-weighted rate of return.
-
.volatility(returns:, sample: true) ⇒ decimal
Standard deviation of periodic returns.
-
.wpr(r:, w:) ⇒ decimal
Weighted mean as a portfolio return.
Class Method Details
.annualize_return(rate:, periods_per_year:) ⇒ decimal
Compound a periodic return into an annual return.
48 49 50 51 52 53 |
# File 'lib/finrb/returns.rb', line 48 def self.annualize_return(rate:, periods_per_year:) rate = Validation.decimal_at_least(rate, minimum: -1, name: 'periodic rate') periods_per_year = Validation.positive_integer(periods_per_year, name: 'periods per year') ((rate + 1)**periods_per_year) - 1 end |
.annualize_volatility(volatility:, periods_per_year:) ⇒ decimal
Scale periodic volatility by the square root of periods per year.
56 57 58 59 60 61 |
# File 'lib/finrb/returns.rb', line 56 def self.annualize_volatility(volatility:, periods_per_year:) volatility = Validation.non_negative_decimal(volatility, name: 'volatility') periods_per_year = Validation.positive_integer(periods_per_year, name: 'periods per year') volatility * (Flt::DecNum(periods_per_year)**Flt::DecNum('0.5')) end |
.cagr(beginning_value:, ending_value:, periods:) ⇒ Flt::DecNum
Compound annual growth rate over a positive number of periods.
Beginning value must be positive. Ending value may be zero, representing a total loss, but cannot be negative because a fractional growth root would not have a generally meaningful real-valued result.
31 32 33 34 35 36 37 |
# File 'lib/finrb/returns.rb', line 31 def self.cagr(beginning_value:, ending_value:, periods:) beginning_value = Validation.positive_decimal(beginning_value, name: 'beginning value') ending_value = Validation.non_negative_decimal(ending_value, name: 'ending value') periods = Validation.positive_integer(periods, name: 'period count') ((ending_value / beginning_value)**(Flt::DecNum(1) / periods)) - 1 end |
.coefficient_variation(sd:, avg:) ⇒ decimal
Computing Coefficient of variation
123 124 125 126 127 128 129 |
# File 'lib/finrb/returns.rb', line 123 def self.coefficient_variation(sd:, avg:) sd = Validation.non_negative_decimal(sd, name: 'standard deviation') avg = Validation.decimal(avg, name: 'average') raise(DomainError, 'Average must be non-zero.') if avg.zero? (sd / avg) end |
.downside_deviation(returns:, target: 0) ⇒ decimal
Root-mean-square return shortfall below a target return. The denominator includes every observation, including returns at or above the target.
79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/finrb/returns.rb', line 79 def self.downside_deviation(returns:, target: 0) returns = risk_values(returns, name: 'return') target = Validation.decimal(target, name: 'target') squared_shortfalls = returns.sum do |value| shortfall = [value - target, Flt::DecNum(0)].min shortfall**2 end (squared_shortfalls / returns.size)**Flt::DecNum('0.5') end |
.geometric_mean(r:) ⇒ decimal
Geometric mean return
136 137 138 139 140 141 142 143 144 |
# File 'lib/finrb/returns.rb', line 136 def self.geometric_mean(r:) returns = risk_values(r, name: 'return') returns.each do |value| raise(DomainError, 'Returns must be greater than or equal to -1.') if value < -1 end growth_factors = returns.map { |value| value + 1 } ((growth_factors.reduce(:*)**(Flt::DecNum(1) / growth_factors.size)) - 1) end |
.harmonic_mean(p:) ⇒ decimal
harmonic mean, average price
150 151 152 153 154 155 |
# File 'lib/finrb/returns.rb', line 150 def self.harmonic_mean(p:) prices = risk_values(p, name: 'price') raise(DomainError, 'Prices must be greater than zero.') unless prices.all?(&:positive?) (Flt::DecNum(1) / (prices.sum { |price| Flt::DecNum(1) / price } / prices.size)) end |
.hpr(ev:, bv:, cfr: 0) ⇒ decimal
Computing HPR, the holding period return
164 165 166 167 168 169 170 |
# File 'lib/finrb/returns.rb', line 164 def self.hpr(ev:, bv:, cfr: 0) ev = Validation.decimal(ev, name: 'ending value') bv = Validation.positive_decimal(bv, name: 'beginning value', error: DomainError) cfr = Validation.decimal(cfr, name: 'cashflow received') ((ev - bv + cfr) / bv) end |
.max_drawdown(values:) ⇒ decimal
Largest peak-to-trough decline as a non-negative fraction.
106 107 108 109 110 111 112 113 114 115 |
# File 'lib/finrb/returns.rb', line 106 def self.max_drawdown(values:) values = risk_values(values, name: 'value') raise(ArgumentError, 'values must be greater than zero.') unless values.all?(&:positive?) peak = values.first values.reduce(Flt::DecNum(0)) do |maximum, value| peak = value if value > peak [maximum, (peak - value) / peak].max end end |
.sampling_error(sm:, mu:) ⇒ decimal
Computing Sampling error
178 179 180 181 182 183 |
# File 'lib/finrb/returns.rb', line 178 def self.sampling_error(sm:, mu:) sm = Validation.decimal(sm, name: 'sample mean') mu = Validation.decimal(mu, name: 'population mean') (sm - mu) end |
.sf_ratio(rp:, rl:, sd:) ⇒ decimal
Computing Roy's safety-first ratio
192 193 194 195 196 197 198 |
# File 'lib/finrb/returns.rb', line 192 def self.sf_ratio(rp:, rl:, sd:) rp = Validation.decimal(rp, name: 'portfolio return') rl = Validation.decimal(rl, name: 'threshold return') sd = Validation.positive_decimal(sd, name: 'standard deviation', error: DomainError) ((rp - rl) / sd) end |
.sharpe_ratio(rp:, rf:, sd:) ⇒ decimal
Computing Sharpe Ratio
207 208 209 210 211 212 213 |
# File 'lib/finrb/returns.rb', line 207 def self.sharpe_ratio(rp:, rf:, sd:) rp = Validation.decimal(rp, name: 'portfolio return') rf = Validation.decimal(rf, name: 'risk-free return') sd = Validation.positive_decimal(sd, name: 'standard deviation', error: DomainError) ((rp - rf) / sd) end |
.sortino_ratio(returns:, target: 0, periods_per_year: nil) ⇒ decimal
Sortino ratio using arithmetic mean excess return and downside deviation.
92 93 94 95 96 97 98 99 100 101 102 103 |
# File 'lib/finrb/returns.rb', line 92 def self.sortino_ratio(returns:, target: 0, periods_per_year: nil) returns = risk_values(returns, name: 'return') target = Validation.decimal(target, name: 'target') downside = downside_deviation(returns:, target:) raise(ArgumentError, 'downside deviation must be greater than zero.') if downside.zero? ratio = ((returns.sum / returns.size) - target) / downside return ratio if periods_per_year.nil? periods_per_year = Validation.positive_integer(periods_per_year, name: 'periods per year') ratio * (Flt::DecNum(periods_per_year)**Flt::DecNum('0.5')) end |
.twrr(ev:, bv:, cfr:) ⇒ decimal
Computing TWRR, the time-weighted rate of return
222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 |
# File 'lib/finrb/returns.rb', line 222 def self.twrr(ev:, bv:, cfr:) ending_values = risk_values(ev, name: 'ending value') beginning_values = risk_values(bv, name: 'beginning value') cashflows_received = risk_values(cfr, name: 'cashflow received') sizes = [ending_values.size, beginning_values.size, cashflows_received.size] raise(ArgumentError, 'Ending values, beginning values, and cashflows received must have equal lengths.') unless sizes.uniq.one? wealth_relative = ending_values.each_index.reduce(Flt::DecNum(1)) do |product, index| period_growth = hpr(ev: ending_values[index], bv: beginning_values[index], cfr: cashflows_received[index]) + 1 raise(DomainError, 'Each subperiod wealth relative must be greater than or equal to zero.') if period_growth.negative? product * period_growth end (wealth_relative**(Flt::DecNum(1) / ending_values.size)) - 1 end |
.volatility(returns:, sample: true) ⇒ decimal
Standard deviation of periodic returns. Sample volatility uses n - 1; population volatility uses n.
65 66 67 68 69 70 71 72 73 74 75 |
# File 'lib/finrb/returns.rb', line 65 def self.volatility(returns:, sample: true) raise(ArgumentError, 'sample must be true or false.') unless [true, false].include?(sample) returns = risk_values(returns, name: 'return') raise(ArgumentError, 'sample volatility requires at least two returns.') if sample && returns.size < 2 mean = returns.sum / returns.size denominator = sample ? returns.size - 1 : returns.size variance = returns.sum { |value| (value - mean)**2 } / denominator variance**Flt::DecNum('0.5') end |
.wpr(r:, w:) ⇒ decimal
Weighted mean as a portfolio return
245 246 247 248 249 250 251 252 |
# File 'lib/finrb/returns.rb', line 245 def self.wpr(r:, w:) returns = risk_values(r, name: 'return') weights = risk_values(w, name: 'weight') raise(ArgumentError, 'Returns and weights must have equal lengths.') unless returns.size == weights.size raise(ArgumentError, 'Weights must sum to 1.') unless weights.sum == 1 returns.zip(weights).sum { |rate, weight| rate * weight } end |