fundamentalista 0.3.0 → 0.5.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: 60ef7c2913952daa4c9a4bcf633813d421326b9cc9ccc55755731ef536c51785
4
+ data.tar.gz: 712549d9e3a33d73e2ce21ca19c5780267cdb64a02261b546dff0e62ac05c3e2
5
5
  SHA512:
6
- metadata.gz: a8f4e210ea59fac412647858620bd23f880785fb723c902770e81b295bdfd0671884affb0b1a849e165f49f0d2dc90660c5978374f673b5038b8e5ef6002cf6b
7
- data.tar.gz: 438bf83861f879aa1c4f4b47468ffdb974ab1d3700aaa5ea8ec8375a8da891e689bc21bbc7ce11b1e50b290351342da399040e185fd5c6f06d4e2392389e46b5
6
+ metadata.gz: a5d017ed989ad8ba888720041675e15bd683fdf391dcfb9dbdf5d14918f9acc88581854eeef5ed4995f69b3ad25074f5e60f1df63e420e03481ed387b342a302
7
+ data.tar.gz: f839a5b66257300a5b139fac8d303b031ebfd282878b16bdaa6a79dd643d59a905b89499d1ab93195f623190b74d5c66cd5cf4fe36792ceddde8c4c4e0af364b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ - Exchange `Rates` from FMP or your own, and comparisons in one currency that convert amounts and leave ratios alone.
6
+
7
+ ## 0.4.0
8
+
9
+ - Revenue by product line and by region from FMP as `Segments`, with shares and growth per segment.
10
+ - Greenwald's earnings power value and the residual income model on the valuation.
11
+
3
12
  ## 0.3.0
4
13
 
5
14
  - 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
@@ -190,7 +190,15 @@ peers.rank(:pe, ascending: true)
190
190
  peers.magic_formula
191
191
  ```
192
192
 
193
- Tickers the provider cannot resolve are logged and left out, so one bad symbol does not spoil a screen. Absolute figures such as revenue stay in each company's reporting currency; ratios compare across currencies, amounts do not.
193
+ Tickers the provider cannot resolve are logged and left out, so one bad symbol does not spoil a screen.
194
+
195
+ Ratios and multiples compare across currencies as they are; amounts do not. Pass `currency:` and the amounts, revenue, market cap, book value per share and the rest, are converted through exchange `Rates`, fetched from FMP or given by you on EDGAR, while everything that is a rate or a multiple stays untouched.
196
+
197
+ ```ruby
198
+ rates = Fundamentalista::Rates.new({ "DKK" => 0.156, "EUR" => 1.16 }, to: "USD")
199
+ Fundamentalista.compare("AAPL", "NVO", "SAP", currency: "USD", rates: rates).values(:revenue)
200
+ Fundamentalista.compare("AAPL", "NVO", currency: "USD", provider: :fmp).table(:revenue, :net_margin) # rates from FMP
201
+ ```
194
202
 
195
203
  ## Provenance
196
204
 
@@ -285,6 +293,26 @@ company.valuation_history(:pe, :pb, :fcf_yield) # {2025 => {price:
285
293
  company.valuation_history(:pe, prices: prices) # with a history you bring
286
294
  ```
287
295
 
296
+ ## Segments
297
+
298
+ 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.
299
+
300
+ ```ruby
301
+ segments = company.segments(by: :product) # or :geography
302
+ segments.latest # {"iPhone" => ..., "Service" => ...}
303
+ segments.share # each segment's share of the latest year
304
+ segments.growth("Service", years: 5) # CAGR of one segment
305
+ ```
306
+
307
+ ### More valuation models
308
+
309
+ 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.
310
+
311
+ ```ruby
312
+ valuation.earnings_power_value(discount_rate: 0.09)
313
+ valuation.residual_income_value(cost_of_equity: 0.09, growth: 0.04, years: 10)
314
+ ```
315
+
288
316
  ## Providers
289
317
 
290
318
  | | EDGAR | Financial Modeling Prep |
@@ -294,6 +322,8 @@ company.valuation_history(:pe, prices: prices) # with a history y
294
322
  | Quotes | No, pass a price | Yes |
295
323
  | Analyst estimates | No, pass an EPS | Yes |
296
324
  | Prices and beta | No, bring a PriceHistory and a beta | Yes |
325
+ | Revenue segments | No | Yes |
326
+ | Exchange rates | No, bring Rates | Yes |
297
327
  | Coverage | Companies filing with the SEC, US GAAP and IFRS, in their reporting currency | Global |
298
328
 
299
329
  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)
@@ -3,12 +3,15 @@
3
3
  module Fundamentalista
4
4
  # Several companies side by side: any ratio, valuation figure, score or
5
5
  # line item as a table, rankings on any of them, and Greenblatt's magic
6
- # formula ranking.
6
+ # formula ranking. With a +currency+, amounts such as revenue or market
7
+ # cap are converted into it through Rates, while ratios and multiples
8
+ # stay as they are.
7
9
  #
8
10
  # peers = Fundamentalista.compare("AAPL", "MSFT", prices: { "AAPL" => 320, "MSFT" => 500 })
9
11
  # peers.table(:roe, :pe, :piotroski) # => {"AAPL" => {roe: ..., pe: ..., piotroski: 8}, ...}
10
12
  # peers.rank(:pe, ascending: true) # => ["MSFT", "AAPL"]
11
13
  # peers.magic_formula # => ["AAPL", "MSFT"]
14
+ # Fundamentalista.compare("AAPL", "NVO", currency: "USD").values(:revenue)
12
15
  #
13
16
  class Comparison
14
17
  include Enumerable
@@ -20,9 +23,25 @@ module Fundamentalista
20
23
 
21
24
  attr_reader :companies
22
25
 
23
- def initialize(companies, prices: {})
26
+ # The currency amounts are converted into, or +nil+ to leave each
27
+ # company in its own.
28
+ attr_reader :currency
29
+
30
+ def initialize(companies, prices: {}, currency: nil, rates: nil)
24
31
  @companies = companies.to_h { |company| [company.ticker, company] }
25
32
  @prices = prices.transform_keys(&:to_s)
33
+ @currency = currency
34
+ @rates = rates
35
+ end
36
+
37
+ # The Rates in use, fetched from the first company's provider when
38
+ # none were given.
39
+ def rates
40
+ return nil unless currency
41
+
42
+ @rates ||= companies.values.first.provider.rates(companies.values.map do |company|
43
+ company.latest_period.currency
44
+ end, to: currency)
26
45
  end
27
46
 
28
47
  def each(&)
@@ -67,10 +86,18 @@ module Fundamentalista
67
86
  period = company.latest_period
68
87
  valuation = valuation_of(company, period)
69
88
  return period.ratios.public_send(name) if Ratios::ALL.include?(name)
70
- return valuation&.public_send(name) if Valuation::ALL.include?(name)
89
+ if Valuation::ALL.include?(name)
90
+ return converted(valuation&.public_send(name), period, amount: Valuation::AMOUNTS.include?(name))
91
+ end
71
92
  return SCORES[name].call(period, valuation) if SCORES.key?(name)
72
93
 
73
- period.metric(name)
94
+ converted(period.metric(name), period, amount: !name.end_with?('shares_outstanding', 'diluted_shares'))
95
+ end
96
+
97
+ def converted(value, period, amount:)
98
+ return value if value.nil? || !amount || currency.nil? || period.currency == currency
99
+
100
+ rates.convert(value, from: period.currency)
74
101
  end
75
102
 
76
103
  def valuation_of(company, period)
@@ -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, and exchange Rates. Subclasses
7
+ # get an HTTP client 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,14 @@ module Fundamentalista
39
39
  raise NotImplementedError
40
40
  end
41
41
 
42
+ def segments(company, by:)
43
+ raise NotImplementedError
44
+ end
45
+
46
+ def rates(currencies, to:)
47
+ raise NotImplementedError
48
+ end
49
+
42
50
  private
43
51
 
44
52
  def get(url, params = {}, headers: {})
@@ -44,6 +44,14 @@ 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
+
51
+ def rates(_currencies, **)
52
+ raise QuoteUnavailableError, 'EDGAR has no exchange rates; pass Rates'
53
+ end
54
+
47
55
  private
48
56
 
49
57
  STATEMENTS = { income: [IncomeStatement, Tags::INCOME], balance: [BalanceSheet, Tags::BALANCE],
@@ -4,7 +4,8 @@ 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, revenue segments and
8
+ # exchange rates.
8
9
  class FMP < Provider
9
10
  BASE_URL = 'https://financialmodelingprep.com/stable/'
10
11
 
@@ -39,6 +40,22 @@ module Fundamentalista
39
40
  PriceHistory.new(points)
40
41
  end
41
42
 
43
+ SEGMENTS = { product: 'revenue-product-segmentation', geography: 'revenue-geographic-segments' }.freeze
44
+
45
+ def segments(company, by: :product)
46
+ endpoint = SEGMENTS.fetch(by) { raise ArgumentError, "Segments come by :product or :geography, not #{by.inspect}" }
47
+ rows = fetch(endpoint, symbol: company.ticker, period: 'annual')
48
+ Segments.new(rows.to_h { |row| [row['fiscalYear'].to_i, row['data'] || {}] })
49
+ end
50
+
51
+ def rates(currencies, to: 'USD')
52
+ pairs = currencies.map(&:to_s).uniq - [to]
53
+ return Rates.new({}, to: to) if pairs.empty?
54
+
55
+ rows = fetch('batch-quote-short', symbols: pairs.map { |currency| "#{currency}#{to}" }.join(','))
56
+ Rates.new(rows.to_h { |row| [row['symbol'].delete_suffix(to), row['price']] }, to: to)
57
+ end
58
+
42
59
  def estimates(company)
43
60
  fetch('analyst-estimates', symbol: company.ticker, period: 'annual', limit: 10).map do |row|
44
61
  Estimate.new(fiscal_year_end: Date.parse(row['date']), eps: row['epsAvg'], eps_low: row['epsLow'],
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fundamentalista
4
+ # Exchange rates into one base currency: how many units of the base one
5
+ # unit of each currency buys. Converts amounts so companies reporting in
6
+ # different currencies can be put side by side.
7
+ #
8
+ # rates = Fundamentalista::Rates.new({ "EUR" => 1.16, "JPY" => 0.0068 }, to: "USD")
9
+ # rates.convert(36_800_000_000, from: "EUR") # => 0.42688e11
10
+ #
11
+ class Rates
12
+ include Inspectable
13
+
14
+ # The ISO code every amount converts into.
15
+ attr_reader :base
16
+
17
+ def initialize(rates, to: 'USD')
18
+ @base = to
19
+ @rates = rates.to_h { |currency, rate| [currency.to_s, Decimal.wrap(rate)] }
20
+ end
21
+
22
+ # Returns the rate of +currency+ into the base, 1 for the base itself,
23
+ # or +nil+ when unknown.
24
+ def [](currency)
25
+ currency.to_s == base ? BigDecimal('1') : @rates[currency.to_s]
26
+ end
27
+
28
+ def known?(currency)
29
+ !self[currency].nil?
30
+ end
31
+
32
+ # Returns +amount+ in the base currency, or +nil+ when the amount or
33
+ # the rate is missing.
34
+ def convert(amount, from:)
35
+ rate = self[from]
36
+ amount && rate && (Decimal.wrap(amount) * rate)
37
+ end
38
+
39
+ def inspect_attributes # :nodoc:
40
+ { base: base, currencies: @rates.keys }
41
+ end
42
+ end
43
+ end
@@ -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
@@ -18,7 +18,8 @@ module Fundamentalista
18
18
 
19
19
  MULTIPLES = %i[pe forward_pe pb ps ev_to_ebitda ev_to_ebit ev_to_sales peg].freeze
20
20
  YIELDS = %i[earnings_yield ebit_yield fcf_yield dividend_yield shareholder_yield payout_ratio].freeze
21
- ALL = (MULTIPLES + YIELDS + %i[market_cap enterprise_value book_value_per_share graham_number]).freeze
21
+ AMOUNTS = %i[market_cap enterprise_value book_value_per_share graham_number].freeze
22
+ ALL = (MULTIPLES + YIELDS + AMOUNTS).freeze
22
23
 
23
24
  attr_reader :period, :quote, :estimate
24
25
 
@@ -179,6 +180,38 @@ module Fundamentalista
179
180
  ((market_cap * equity_cost) + (debt * debt_cost)) / capital
180
181
  end
181
182
 
183
+ # Greenwald's earnings power value per share: the after tax EBIT the
184
+ # business earns today, capitalized at the +discount_rate+ as if it
185
+ # never grew, net of debt. What the company is worth with no growth
186
+ # priced in; compare it with the price to see how much growth is.
187
+ def earnings_power_value(discount_rate:)
188
+ nopat = income.ebit && income.tax_rate && (income.ebit * (1 - income.tax_rate))
189
+ return nil if nopat.nil? || shares_outstanding.nil?
190
+
191
+ equity = (nopat / Decimal.wrap(discount_rate)) - (balance.net_debt || 0)
192
+ equity / shares_outstanding
193
+ end
194
+
195
+ # The residual income value per share: book value plus the present
196
+ # value of the earnings above the +cost_of_equity+ charge on it, grown
197
+ # at +growth+ for +years+ and then fading to nothing. Rewards returns
198
+ # on equity above the cost of equity, and nothing else.
199
+ def residual_income_value(cost_of_equity:, growth: BigDecimal('0'), years: 10)
200
+ book = balance.equity
201
+ earnings = income.net_income
202
+ return nil if book.nil? || earnings.nil? || shares_outstanding.nil?
203
+
204
+ rate = Decimal.wrap(cost_of_equity)
205
+ value = book
206
+ (1..years).each do |year|
207
+ residual = earnings - (rate * book)
208
+ value += residual / ((1 + rate)**year)
209
+ earnings *= 1 + Decimal.wrap(growth)
210
+ book += earnings * (1 - (payout_ratio || 0))
211
+ end
212
+ value / shares_outstanding
213
+ end
214
+
182
215
  # The reverse DCF: the yearly free cash flow growth the price implies
183
216
  # under the given +discount_rate+ and the other DCF keywords. +nil+
184
217
  # 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.5.0'
6
6
  end
@@ -36,9 +36,10 @@ module Fundamentalista
36
36
  end
37
37
 
38
38
  # Returns a Comparison of the companies behind +tickers+, valued at
39
- # +prices+ where given. Tickers the provider cannot resolve are logged
40
- # and left out.
41
- def compare(*tickers, prices: {}, provider: config.default_provider)
39
+ # +prices+ where given, with amounts converted into +currency+ through
40
+ # the provider's Rates or the +rates+ you pass. Tickers the provider
41
+ # cannot resolve are logged and left out.
42
+ def compare(*tickers, prices: {}, currency: nil, rates: nil, provider: config.default_provider)
42
43
  source = provider(provider)
43
44
  companies = tickers.flatten.filter_map do |ticker|
44
45
  source.company(ticker)
@@ -46,7 +47,7 @@ module Fundamentalista
46
47
  logger.warn("Skipping #{ticker}: #{e.message}")
47
48
  nil
48
49
  end
49
- Comparison.new(companies, prices: prices)
50
+ Comparison.new(companies, prices: prices, currency: currency, rates: rates)
50
51
  end
51
52
 
52
53
  # Returns an instance of the provider registered under +slug+.
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.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bruno Costanzo
@@ -101,10 +101,12 @@ files:
101
101
  - lib/fundamentalista/providers/edgar/tags.rb
102
102
  - lib/fundamentalista/providers/fmp.rb
103
103
  - lib/fundamentalista/quote.rb
104
+ - lib/fundamentalista/rates.rb
104
105
  - lib/fundamentalista/ratios.rb
105
106
  - lib/fundamentalista/scores/altman_z.rb
106
107
  - lib/fundamentalista/scores/beneish.rb
107
108
  - lib/fundamentalista/scores/piotroski.rb
109
+ - lib/fundamentalista/segments.rb
108
110
  - lib/fundamentalista/serializable.rb
109
111
  - lib/fundamentalista/source.rb
110
112
  - lib/fundamentalista/statement.rb