Module: Finrb::Returns

Defined in:
lib/finrb/returns.rb,
sig/finrb.rbs

Overview

Investment return and risk-adjusted performance calculations.

Class Method Summary collapse

Class Method Details

.annualize_return(rate:, periods_per_year:) ⇒ decimal

Compound a periodic return into an annual return.

Parameters:

  • rate: (number)
  • periods_per_year: (Integer)

Returns:

  • (decimal)


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.

Parameters:

  • volatility: (number)
  • periods_per_year: (Integer)

Returns:

  • (decimal)


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.

Parameters:

  • beginning_value (Numeric)

    value at the start of the measurement

  • ending_value (Numeric)

    value at the end of the measurement

  • periods (Integer)

    number of equal annual periods

  • beginning_value: (number)
  • ending_value: (number)
  • periods: (Integer)

Returns:



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

Examples:

Finrb::Returns.coefficient_variation(sd=0.15,avg=0.39)

Parameters:

  • sd

    standard deviation

  • avg

    average value

  • sd: (number)
  • avg: (number)

Returns:

  • (decimal)

Raises:



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.

Parameters:

  • returns: (number, numbers)
  • target: (number) (defaults to: 0)

Returns:

  • (decimal)


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

Examples:

Finrb::Returns.geometric_mean(r=[-0.0934, 0.2345, 0.0892])

Parameters:

  • r

    returns over multiple periods

  • r: (number, numbers)

Returns:

  • (decimal)


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

Examples:

Finrb::Returns.harmonic_mean(p=[8,9,10])

Parameters:

  • p

    price over multiple periods

  • p: (number, numbers)

Returns:

  • (decimal)

Raises:



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

Examples:

Finrb::Returns.hpr(ev=33,bv=30,cfr=0.5)

Parameters:

  • ev

    ending value

  • bv

    beginning value

  • cfr (defaults to: 0)

    cash flow received

  • ev: (number)
  • bv: (number)
  • cfr: (number) (defaults to: 0)

Returns:

  • (decimal)


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.

Parameters:

  • values: (number, numbers)

Returns:

  • (decimal)

Raises:

  • (ArgumentError)


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

Examples:

Finrb::Returns.sampling_error(sm=0.45, mu=0.5)

Parameters:

  • sm

    sample mean

  • mu

    population mean

  • sm: (number)
  • mu: (number)

Returns:

  • (decimal)


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

Examples:

Finrb::Returns.sf_ratio(rp=0.09,rl=0.03,sd=0.12)

Parameters:

  • rp

    portfolio return

  • rl

    threshold level return

  • sd

    standard deviation of portfolio retwns

  • rp: (number)
  • rl: (number)
  • sd: (number)

Returns:

  • (decimal)


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

Examples:

Finrb::Returns.sharpe_ratio(rp=0.038,rf=0.015,sd=0.07)

Parameters:

  • rp

    portfolio return

  • rf

    risk-free return

  • sd

    standard deviation of portfolio retwns

  • rp: (number)
  • rf: (number)
  • sd: (number)

Returns:

  • (decimal)


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.

Parameters:

  • returns: (number, numbers)
  • target: (number) (defaults to: 0)
  • periods_per_year: (Integer, nil) (defaults to: nil)

Returns:

  • (decimal)

Raises:

  • (ArgumentError)


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

Examples:

Finrb::Returns.twrr(ev=[120,260],bv=[100,240],cfr=[2,4])

Parameters:

  • ev

    ordered ending value list

  • bv

    ordered beginning value list

  • cfr

    ordered cash flow received list

  • ev: (number, numbers)
  • bv: (number, numbers)
  • cfr: (number, numbers)

Returns:

  • (decimal)

Raises:

  • (ArgumentError)


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.

Parameters:

  • returns: (number, numbers)
  • sample: (Boolean) (defaults to: true)

Returns:

  • (decimal)

Raises:

  • (ArgumentError)


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

Examples:

Finrb::Returns.wpr(r=[0.12, 0.07, 0.03],w=[0.5,0.4,0.1])

Parameters:

  • r

    returns of the individual assets in the portfolio

  • w

    corresponding weights associated with each of the individual assets

  • r: (number, numbers)
  • w: (number, numbers)

Returns:

  • (decimal)

Raises:

  • (ArgumentError)


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