fundamentalista 0.4.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: 0d6101d9bee968831b3ffb75a7e6663deb2bb1fc925a2c01b7a4cfcfcfb50fd4
4
- data.tar.gz: 52119b52c85c8bf846516846f9af7e11612bdd6c27048940007c5c798bd368e9
3
+ metadata.gz: 60ef7c2913952daa4c9a4bcf633813d421326b9cc9ccc55755731ef536c51785
4
+ data.tar.gz: 712549d9e3a33d73e2ce21ca19c5780267cdb64a02261b546dff0e62ac05c3e2
5
5
  SHA512:
6
- metadata.gz: fc67da833989bdf483cc1fdabf382bd0f62cdd64539ae7edca030008c94f5380952224b0f83f1e2adc14651d851e2fcea8b03289e4f731a218432e8ed97f924b
7
- data.tar.gz: b6125c1afde39ba4ba9298c3080ce4141ed3afeaecab18bd5a8aa1e10fefd939f68bf3f9d3d91ec8b04a1dbfb4822ad8cca5eb4512953101e057bce92d7434fa
6
+ metadata.gz: a5d017ed989ad8ba888720041675e15bd683fdf391dcfb9dbdf5d14918f9acc88581854eeef5ed4995f69b3ad25074f5e60f1df63e420e03481ed387b342a302
7
+ data.tar.gz: f839a5b66257300a5b139fac8d303b031ebfd282878b16bdaa6a79dd643d59a905b89499d1ab93195f623190b74d5c66cd5cf4fe36792ceddde8c4c4e0af364b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
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
+
3
7
  ## 0.4.0
4
8
 
5
9
  - Revenue by product line and by region from FMP as `Segments`, with shares and growth per segment.
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
 
@@ -315,6 +323,7 @@ valuation.residual_income_value(cost_of_equity: 0.09, growth: 0.04, years: 10)
315
323
  | Analyst estimates | No, pass an EPS | Yes |
316
324
  | Prices and beta | No, bring a PriceHistory and a beta | Yes |
317
325
  | Revenue segments | No | Yes |
326
+ | Exchange rates | No, bring Rates | Yes |
318
327
  | Coverage | Companies filing with the SEC, US GAAP and IFRS, in their reporting currency | Global |
319
328
 
320
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.
@@ -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)
@@ -3,8 +3,8 @@
3
3
  module Fundamentalista
4
4
  # The contract every data source implements: resolve a Company, return
5
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.
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.
@@ -43,6 +43,10 @@ module Fundamentalista
43
43
  raise NotImplementedError
44
44
  end
45
45
 
46
+ def rates(currencies, to:)
47
+ raise NotImplementedError
48
+ end
49
+
46
50
  private
47
51
 
48
52
  def get(url, params = {}, headers: {})
@@ -48,6 +48,10 @@ module Fundamentalista
48
48
  raise UnsupportedPeriodError, "EDGAR's company facts carry no segments for #{company.ticker}"
49
49
  end
50
50
 
51
+ def rates(_currencies, **)
52
+ raise QuoteUnavailableError, 'EDGAR has no exchange rates; pass Rates'
53
+ end
54
+
51
55
  private
52
56
 
53
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, daily prices and revenue segments.
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
 
@@ -47,6 +48,14 @@ module Fundamentalista
47
48
  Segments.new(rows.to_h { |row| [row['fiscalYear'].to_i, row['data'] || {}] })
48
49
  end
49
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
+
50
59
  def estimates(company)
51
60
  fetch('analyst-estimates', symbol: company.ticker, period: 'annual', limit: 10).map do |row|
52
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
@@ -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
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Fundamentalista
4
4
  # The version of the fundamentalista gem, as a string.
5
- VERSION = '0.4.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.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bruno Costanzo
@@ -101,6 +101,7 @@ 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