fundamentalista 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 37f787ea99f5a949329fb943c8994bc1fa3cb3b9f0ed357757926b4bb661fb57
4
- data.tar.gz: f974e488c25c1b0628e9c6fbca2400b6aa86c99705d8d274d008591a0a93d52e
3
+ metadata.gz: 0d6101d9bee968831b3ffb75a7e6663deb2bb1fc925a2c01b7a4cfcfcfb50fd4
4
+ data.tar.gz: 52119b52c85c8bf846516846f9af7e11612bdd6c27048940007c5c798bd368e9
5
5
  SHA512:
6
- metadata.gz: a8f4e210ea59fac412647858620bd23f880785fb723c902770e81b295bdfd0671884affb0b1a849e165f49f0d2dc90660c5978374f673b5038b8e5ef6002cf6b
7
- data.tar.gz: 438bf83861f879aa1c4f4b47468ffdb974ab1d3700aaa5ea8ec8375a8da891e689bc21bbc7ce11b1e50b290351342da399040e185fd5c6f06d4e2392389e46b5
6
+ metadata.gz: fc67da833989bdf483cc1fdabf382bd0f62cdd64539ae7edca030008c94f5380952224b0f83f1e2adc14651d851e2fcea8b03289e4f731a218432e8ed97f924b
7
+ data.tar.gz: b6125c1afde39ba4ba9298c3080ce4141ed3afeaecab18bd5a8aa1e10fefd939f68bf3f9d3d91ec8b04a1dbfb4822ad8cca5eb4512953101e057bce92d7434fa
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ - Revenue by product line and by region from FMP as `Segments`, with shares and growth per segment.
6
+ - Greenwald's earnings power value and the residual income model on the valuation.
7
+
3
8
  ## 0.3.0
4
9
 
5
10
  - Insurance lines and ratios: premiums, claims, loss, expense and combined ratios, investment yield and the float, read from the concepts property and casualty insurers report.
data/README.md CHANGED
@@ -285,6 +285,26 @@ company.valuation_history(:pe, :pb, :fcf_yield) # {2025 => {price:
285
285
  company.valuation_history(:pe, prices: prices) # with a history you bring
286
286
  ```
287
287
 
288
+ ## Segments
289
+
290
+ FMP serves revenue by product line and by region for every fiscal year, as `Segments`: what the company sells and where, and how each part grows. EDGAR's company facts carry no segments.
291
+
292
+ ```ruby
293
+ segments = company.segments(by: :product) # or :geography
294
+ segments.latest # {"iPhone" => ..., "Service" => ...}
295
+ segments.share # each segment's share of the latest year
296
+ segments.growth("Service", years: 5) # CAGR of one segment
297
+ ```
298
+
299
+ ### More valuation models
300
+
301
+ Two models complement the DCF. Greenwald's earnings power value capitalizes today's after tax EBIT at the discount rate as if the company never grew, so the gap to the price is the growth being paid for. The residual income model starts from book value and adds only the earnings above the cost of equity's charge on it, so it rewards returns on equity above that cost and nothing else.
302
+
303
+ ```ruby
304
+ valuation.earnings_power_value(discount_rate: 0.09)
305
+ valuation.residual_income_value(cost_of_equity: 0.09, growth: 0.04, years: 10)
306
+ ```
307
+
288
308
  ## Providers
289
309
 
290
310
  | | EDGAR | Financial Modeling Prep |
@@ -294,6 +314,7 @@ company.valuation_history(:pe, prices: prices) # with a history y
294
314
  | Quotes | No, pass a price | Yes |
295
315
  | Analyst estimates | No, pass an EPS | Yes |
296
316
  | Prices and beta | No, bring a PriceHistory and a beta | Yes |
317
+ | Revenue segments | No | Yes |
297
318
  | Coverage | Companies filing with the SEC, US GAAP and IFRS, in their reporting currency | Global |
298
319
 
299
320
  EDGAR publishes every value a company ever tagged, restatements included. Fundamentalista reads each line item for the period it describes and keeps the most recently filed value, while the fiscal year label comes from the original filing. Companies tag the same idea under different XBRL concepts, so each line item has an ordered list of concepts in `Providers::Edgar::Tags`, and the first one reported wins. Debt excludes lease obligations, which FMP's `totalDebt` includes.
@@ -12,6 +12,7 @@ module Fundamentalista
12
12
  # company.valuation(price: 320).pe
13
13
  # company.estimate.eps # FMP only
14
14
  # company.valuation_history(:pe) # {2025 => {price:, pe:}, ...}
15
+ # company.segments(by: :geography).share # FMP only
15
16
  #
16
17
  class Company
17
18
  include Inspectable
@@ -65,6 +66,13 @@ module Fundamentalista
65
66
  end.to_h
66
67
  end
67
68
 
69
+ # Returns the revenue Segments by +:product+ or +:geography+. Raises
70
+ # UnsupportedPeriodError on EDGAR, whose company facts carry none.
71
+ def segments(by: :product)
72
+ @segments ||= {}
73
+ @segments[by] ||= provider.segments(self, by: by)
74
+ end
75
+
68
76
  # Returns the trailing twelve months as a Period, built from the last
69
77
  # +quarters+ quarterly periods; eight give it a prior period too.
70
78
  def ttm(quarters: 8)
@@ -2,9 +2,9 @@
2
2
 
3
3
  module Fundamentalista
4
4
  # The contract every data source implements: resolve a Company, return
5
- # its Financials, a Quote or +nil+, the analyst Estimates it has, and
6
- # its PriceHistory. Subclasses get an HTTP client with retries and error
7
- # mapping.
5
+ # its Financials, a Quote or +nil+, the analyst Estimates it has, its
6
+ # PriceHistory and its revenue Segments. Subclasses get an HTTP client
7
+ # with retries and error mapping.
8
8
  class Provider
9
9
  class << self
10
10
  # The symbol the provider is registered under.
@@ -39,6 +39,10 @@ module Fundamentalista
39
39
  raise NotImplementedError
40
40
  end
41
41
 
42
+ def segments(company, by:)
43
+ raise NotImplementedError
44
+ end
45
+
42
46
  private
43
47
 
44
48
  def get(url, params = {}, headers: {})
@@ -44,6 +44,10 @@ module Fundamentalista
44
44
  raise QuoteUnavailableError, "EDGAR has no prices for #{company.ticker}; pass a PriceHistory"
45
45
  end
46
46
 
47
+ def segments(company, **)
48
+ raise UnsupportedPeriodError, "EDGAR's company facts carry no segments for #{company.ticker}"
49
+ end
50
+
47
51
  private
48
52
 
49
53
  STATEMENTS = { income: [IncomeStatement, Tags::INCOME], balance: [BalanceSheet, Tags::BALANCE],
@@ -4,7 +4,7 @@ module Fundamentalista
4
4
  module Providers
5
5
  # Financial Modeling Prep. Needs Configuration#fmp_api_key. Serves
6
6
  # annual and quarterly periods, company profiles with beta, live
7
- # quotes, analyst estimates and daily prices.
7
+ # quotes, analyst estimates, daily prices and revenue segments.
8
8
  class FMP < Provider
9
9
  BASE_URL = 'https://financialmodelingprep.com/stable/'
10
10
 
@@ -39,6 +39,14 @@ module Fundamentalista
39
39
  PriceHistory.new(points)
40
40
  end
41
41
 
42
+ SEGMENTS = { product: 'revenue-product-segmentation', geography: 'revenue-geographic-segments' }.freeze
43
+
44
+ def segments(company, by: :product)
45
+ endpoint = SEGMENTS.fetch(by) { raise ArgumentError, "Segments come by :product or :geography, not #{by.inspect}" }
46
+ rows = fetch(endpoint, symbol: company.ticker, period: 'annual')
47
+ Segments.new(rows.to_h { |row| [row['fiscalYear'].to_i, row['data'] || {}] })
48
+ end
49
+
42
50
  def estimates(company)
43
51
  fetch('analyst-estimates', symbol: company.ticker, period: 'annual', limit: 10).map do |row|
44
52
  Estimate.new(fiscal_year_end: Date.parse(row['date']), eps: row['epsAvg'], eps_low: row['epsLow'],
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fundamentalista
4
+ # Revenue by product line or by region over the years: what a company
5
+ # actually sells and where. Amounts are in the reporting currency,
6
+ # keyed by fiscal year, newest first.
7
+ #
8
+ # segments = company.segments(by: :product) # FMP
9
+ # segments.latest # => {"iPhone" => 0.209586e12, "Service" => 0.109158e12, ...}
10
+ # segments.share # => {"iPhone" => 0.503e0, ...}
11
+ # segments.growth("Service", years: 5)
12
+ #
13
+ class Segments
14
+ include Inspectable
15
+ include Serializable
16
+
17
+ def initialize(years)
18
+ @years = years.sort_by { |year, _| -year }.to_h do |year, amounts|
19
+ [year, amounts.transform_values do |amount|
20
+ Decimal.wrap(amount)
21
+ end]
22
+ end
23
+ end
24
+
25
+ # The fiscal years on file, newest first.
26
+ def years
27
+ @years.keys
28
+ end
29
+
30
+ # Every segment name that appears in any year.
31
+ def names
32
+ @years.values.flat_map(&:keys).uniq
33
+ end
34
+
35
+ # The amounts of +year+, the latest by default.
36
+ def [](year)
37
+ @years[year]
38
+ end
39
+
40
+ def latest
41
+ @years.values.first
42
+ end
43
+
44
+ # Returns each segment's share of the year's total, as rates.
45
+ def share(year = years.first)
46
+ amounts = @years.fetch(year, {})
47
+ total = amounts.values.sum(BigDecimal('0'))
48
+ amounts.transform_values { |amount| Decimal.ratio(amount, total) }
49
+ end
50
+
51
+ # Returns the compound annual growth of +name+ from the year +years+
52
+ # back to the latest, or over the whole history by default.
53
+ def growth(name, years: nil)
54
+ span = years ? [years, self.years.size - 1].min : self.years.size - 1
55
+ return nil if span < 1
56
+
57
+ first = self.years[span]
58
+ Decimal.cagr(@years.dig(first, name), @years.dig(self.years.first, name), self.years.first - first)
59
+ end
60
+
61
+ def to_h
62
+ @years
63
+ end
64
+
65
+ def inspect_attributes # :nodoc:
66
+ { years: years.size, latest: years.first, segments: names.size }
67
+ end
68
+ end
69
+ end
@@ -179,6 +179,38 @@ module Fundamentalista
179
179
  ((market_cap * equity_cost) + (debt * debt_cost)) / capital
180
180
  end
181
181
 
182
+ # Greenwald's earnings power value per share: the after tax EBIT the
183
+ # business earns today, capitalized at the +discount_rate+ as if it
184
+ # never grew, net of debt. What the company is worth with no growth
185
+ # priced in; compare it with the price to see how much growth is.
186
+ def earnings_power_value(discount_rate:)
187
+ nopat = income.ebit && income.tax_rate && (income.ebit * (1 - income.tax_rate))
188
+ return nil if nopat.nil? || shares_outstanding.nil?
189
+
190
+ equity = (nopat / Decimal.wrap(discount_rate)) - (balance.net_debt || 0)
191
+ equity / shares_outstanding
192
+ end
193
+
194
+ # The residual income value per share: book value plus the present
195
+ # value of the earnings above the +cost_of_equity+ charge on it, grown
196
+ # at +growth+ for +years+ and then fading to nothing. Rewards returns
197
+ # on equity above the cost of equity, and nothing else.
198
+ def residual_income_value(cost_of_equity:, growth: BigDecimal('0'), years: 10)
199
+ book = balance.equity
200
+ earnings = income.net_income
201
+ return nil if book.nil? || earnings.nil? || shares_outstanding.nil?
202
+
203
+ rate = Decimal.wrap(cost_of_equity)
204
+ value = book
205
+ (1..years).each do |year|
206
+ residual = earnings - (rate * book)
207
+ value += residual / ((1 + rate)**year)
208
+ earnings *= 1 + Decimal.wrap(growth)
209
+ book += earnings * (1 - (payout_ratio || 0))
210
+ end
211
+ value / shares_outstanding
212
+ end
213
+
182
214
  # The reverse DCF: the yearly free cash flow growth the price implies
183
215
  # under the given +discount_rate+ and the other DCF keywords. +nil+
184
216
  # when no growth between -50% and +100% reproduces the price.
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Fundamentalista
4
4
  # The version of the fundamentalista gem, as a string.
5
- VERSION = '0.3.0'
5
+ VERSION = '0.4.0'
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fundamentalista
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bruno Costanzo
@@ -105,6 +105,7 @@ files:
105
105
  - lib/fundamentalista/scores/altman_z.rb
106
106
  - lib/fundamentalista/scores/beneish.rb
107
107
  - lib/fundamentalista/scores/piotroski.rb
108
+ - lib/fundamentalista/segments.rb
108
109
  - lib/fundamentalista/serializable.rb
109
110
  - lib/fundamentalista/source.rb
110
111
  - lib/fundamentalista/statement.rb