Module: Timeprice::Forecast::Cagr Private

Defined in:
lib/timeprice/forecast/cagr.rb

Overview

This module is part of a private API. You should avoid using this module if possible, as it may be removed or be changed in the future.

Pure math: trailing CAGR and σ of year-over-year changes.

The series is a hash mapping date strings (‘“YYYY”` or `“YYYY-MM”`) to numeric values. The trailing window is anchored on `last_date` and extends `window_years` backward. CAGR is the annualized geometric return between the first and last samples in the window. Sigma is the sample stdev of 1-year-spaced returns within the window.

No I/O, no DataLoader. Pure function — call from anywhere.

Class Method Summary collapse

Class Method Details

.annualised_return(sorted) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/timeprice/forecast/cagr.rb', line 47

def annualised_return(sorted)
  first_v = sorted.first.last.to_f
  last_v  = sorted.last.last.to_f

  fail ArgumentError, "first window value must be positive (got #{first_v})" unless first_v.positive?
  fail ArgumentError, "last window value must be positive (got #{last_v})"   unless last_v.positive?

  years_elapsed = (parse(sorted.last.first) - parse(sorted.first.first)) / 365.2425
  fail ArgumentError, "window has zero elapsed time" unless years_elapsed.positive?

  ((last_v / first_v)**(1.0 / years_elapsed)) - 1.0
end

.compute(series:, last_date:, window_years:) ⇒ Hash

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns { cagr: Float, sigma_yoy: Float, window_start: String, window_end: String, samples: Integer }.

Parameters:

  • series (Hash{String => Numeric})
  • last_date (String)

    anchor (“YYYY” or “YYYY-MM”)

  • window_years (Integer)

Returns:

  • (Hash)

    { cagr: Float, sigma_yoy: Float, window_start: String, window_end: String, samples: Integer }



26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/timeprice/forecast/cagr.rb', line 26

def compute(series:, last_date:, window_years:)
  end_date   = parse(last_date)
  start_date = shift_years(end_date, -window_years)

  sorted = series
           .select { |k, _| within?(k, start_date, end_date) }
           .sort_by { |k, _| parse(k) }

  fail ArgumentError, "need at least 2 points in window" if sorted.size < 2

  cagr = annualised_return(sorted)

  {
    cagr: cagr,
    sigma_yoy: stdev_of_yoy(sorted),
    window_start: sorted.first.first,
    window_end: sorted.last.first,
    samples: sorted.size,
  }
end

.parse(s) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



60
61
62
63
64
65
66
# File 'lib/timeprice/forecast/cagr.rb', line 60

def parse(s)
  s = s.to_s
  return ::Date.new(s.to_i, 1, 1) if s.length == 4

  y, m = s.split("-").map(&:to_i)
  ::Date.new(y, m, 1)
end

.shift_years(date, years) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



68
69
70
# File 'lib/timeprice/forecast/cagr.rb', line 68

def shift_years(date, years)
  ::Date.new(date.year + years, date.month, 1)
end

.stdev_of_yoy(sorted) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Stdev of simple (arithmetic) 1-year-spaced returns within the window. Returns 0.0 when fewer than 2 paired samples exist.



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/timeprice/forecast/cagr.rb', line 79

def stdev_of_yoy(sorted)
  by_date = sorted.to_h
  returns = sorted.filter_map do |key, value|
    prior_key = shift_years(parse(key), -1).strftime(key.length == 4 ? "%Y" : "%Y-%m")
    prior = by_date[prior_key]
    next unless prior&.positive?

    (value.to_f / prior) - 1.0
  end
  return 0.0 if returns.size < 2

  mean = returns.sum / returns.size
  variance = returns.sum { |r| (r - mean)**2 } / (returns.size - 1)
  Math.sqrt(variance)
end

.within?(key, start_date, end_date) ⇒ Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Returns:

  • (Boolean)


72
73
74
75
# File 'lib/timeprice/forecast/cagr.rb', line 72

def within?(key, start_date, end_date)
  d = parse(key)
  d.between?(start_date, end_date)
end