fundamentalista 0.4.0 → 0.6.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: eeff95349adcf9312850781f5792f603793c0a104cd49a370d7895eddb9bf334
4
+ data.tar.gz: 1268d8bd7117f7a874de9d8fd65b51f13d70d2b5b1aae5b106e0426b20b1f852
5
5
  SHA512:
6
- metadata.gz: fc67da833989bdf483cc1fdabf382bd0f62cdd64539ae7edca030008c94f5380952224b0f83f1e2adc14651d851e2fcea8b03289e4f731a218432e8ed97f924b
7
- data.tar.gz: b6125c1afde39ba4ba9298c3080ce4141ed3afeaecab18bd5a8aa1e10fefd939f68bf3f9d3d91ec8b04a1dbfb4822ad8cca5eb4512953101e057bce92d7434fa
6
+ metadata.gz: db32e147665acf8f33c7a0d8afe0df06377bd4273b70a33064f077919bf8a7684e06885a17c25b54921578dcf8e66863b921cb481c947e987b2e4181edfa2f2f
7
+ data.tar.gz: aa1d5c94637e79a7f215b3bf7d0532ca1f6636a8ca5df2c233621b3237af58ac39fcf0664600d5baf5634a6c191378f79a9cf5a87434b3bddfb30e05155a529e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ - Derived fourth quarters keep the annual weighted average shares and compute diluted EPS instead of subtracting year to date values; the `:year_to_date` derivation says so on the `Source`.
6
+ - Fourth quarters an older 10-K labels `Q4`, revenue read from the concept the latest filing uses, currency from the statements rather than USD by default, and one currency per company when a filer tags several.
7
+ - Concepts for IFRS depreciation, borrowings, sales of goods, CECL credit provisions, insurance reserves summed from claims and future policy benefits, premiums receivable and reinsurance recoverables.
8
+ - Expenses and outflows as magnitudes whichever sign the filer gave them; `bank?` requires positive net interest income.
9
+ - Dividends per share on the cash flow statement, read from the filing before it is derived.
10
+ - NOPAT on the income statement, ROIC and Greenblatt's return on capital built on it, net working capital without inventory, and a floor on negative working capital.
11
+ - `DCF` rejects missing inputs and earnings power value is `nil` at a zero discount rate; the residual income model grows earnings before charging for the book.
12
+ - `Company#financials` raises `NoFinancialsError` when a filer has no statements, and every error inherits `Fundamentalista::Error`.
13
+ - `Company` and `Source` serialize, `Period#to_h` carries its sources, `Valuation#to_h` carries the price, and `as_json` returns string keys.
14
+ - Companies looked up by CIK, comparisons over trailing twelve months with `period: :ttm`, and one company facts document per company per provider.
15
+
16
+ ## 0.5.0
17
+
18
+ - Exchange `Rates` from FMP or your own, and comparisons in one currency that convert amounts and leave ratios alone.
19
+
3
20
  ## 0.4.0
4
21
 
5
22
  - Revenue by product line and by region from FMP as `Segments`, with shares and growth per segment.
data/README.md CHANGED
@@ -51,6 +51,7 @@ apple = Fundamentalista.company("AAPL")
51
51
  apple = Fundamentalista.company("AAPL", provider: :fmp)
52
52
  apple.name # => "Apple Inc."
53
53
  apple.cik # => 320193
54
+ exxon = Fundamentalista.company(34088) # a CIK, for registrants the ticker list no longer carries
54
55
  ```
55
56
 
56
57
  `financials` returns the reporting history as `Financials`, an `Enumerable` of `Period`s ordered newest first. Each `Period` carries its `income`, `balance` and `cash_flow` statements and knows the period before it, which is what average balances, growth and the Piotroski score are built on.
@@ -84,7 +85,7 @@ ttm.growth(:revenue) # against the previous twelve months
84
85
  apple.valuation(price: 320, period: ttm).pe
85
86
  ```
86
87
 
87
- EDGAR reports quarterly cash flows only year to date, and the fourth quarter only as the annual figure. Fundamentalista reads a quarter directly when the filing has a three month value and derives it otherwise as the difference between two year to date values that share a fiscal year start.
88
+ EDGAR reports quarterly cash flows only year to date, and the fourth quarter only as the annual figure. Fundamentalista reads a quarter directly when the filing has a three month value and derives it otherwise as the difference between two year to date values that share a fiscal year start. Weighted average shares are not additive, so a derived fourth quarter keeps the annual average, and diluted EPS is computed from net income and shares rather than derived.
88
89
 
89
90
  ### Statements
90
91
 
@@ -94,9 +95,9 @@ EDGAR reports quarterly cash flows only year to date, and the fourth quarter onl
94
95
  | `BalanceSheet` | cash, short term investments, receivables, inventory, current assets, PP&E, total assets, payables, current liabilities, total liabilities, short and long term debt, total debt, equity, minority interest, retained earnings, shares outstanding | total liabilities, total debt, liquid assets, net debt, working capital, net working capital |
95
96
 
96
97
  EBIT is operating income when the company reports one, and income before tax plus interest expense otherwise, which is what pharmaceutical and oil companies leave you; interest coverage, EV/EBIT, Greenblatt's yield and return on capital and the Altman Z-score all use it. Figures follow the line as reported: receivables are trade receivables, PP&E excludes lease right-of-use assets, cost of revenue is the company's own line.
97
- | `CashFlowStatement` | operating cash flow, capital expenditure, free cash flow, dividends paid, share repurchases | free cash flow, shareholder returns |
98
+ | `CashFlowStatement` | operating cash flow, capital expenditure, free cash flow, dividends paid, dividends per share, share repurchases | free cash flow, shareholder returns |
98
99
 
99
- Outflows are positive amounts: `capital_expenditure` is what was spent.
100
+ Outflows are positive amounts whichever sign the filer gave them: `capital_expenditure` is what was spent.
100
101
 
101
102
  ## Banks
102
103
 
@@ -190,11 +191,19 @@ peers.rank(:pe, ascending: true)
190
191
  peers.magic_formula
191
192
  ```
192
193
 
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.
194
+ Tickers the provider cannot resolve are logged and left out, so one bad symbol does not spoil a screen. Comparisons read the latest fiscal year; pass `period: :ttm` to compare trailing twelve months instead.
195
+
196
+ 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.
197
+
198
+ ```ruby
199
+ rates = Fundamentalista::Rates.new({ "DKK" => 0.156, "EUR" => 1.16 }, to: "USD")
200
+ Fundamentalista.compare("AAPL", "NVO", "SAP", currency: "USD", rates: rates).values(:revenue)
201
+ Fundamentalista.compare("AAPL", "NVO", currency: "USD", provider: :fmp).table(:revenue, :net_margin) # rates from FMP
202
+ ```
194
203
 
195
204
  ## Provenance
196
205
 
197
- Every EDGAR line item remembers where it came from. `period.source(:revenue)` returns the XBRL concept, the form and filing date it was read from, and whether the figure was reported as is, summed from split concepts, or derived from year to date values.
206
+ Every EDGAR line item remembers where it came from. `period.source(:revenue)` returns the XBRL concept, the form and filing date it was read from, and whether the figure was reported as is, summed from split concepts, derived from year to date values, or, for a weighted average, carried from the year to date figure.
198
207
 
199
208
  ```ruby
200
209
  year.source(:revenue)
@@ -315,11 +324,12 @@ valuation.residual_income_value(cost_of_equity: 0.09, growth: 0.04, years: 10)
315
324
  | Analyst estimates | No, pass an EPS | Yes |
316
325
  | Prices and beta | No, bring a PriceHistory and a beta | Yes |
317
326
  | Revenue segments | No | Yes |
327
+ | Exchange rates | No, bring Rates | Yes |
318
328
  | Coverage | Companies filing with the SEC, US GAAP and IFRS, in their reporting currency | Global |
319
329
 
320
330
  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.
321
331
 
322
- Foreign filers report under IFRS in 20-F and 40-F filings with their own taxonomy; the same line items map to it, and `Period#currency` says what the figures are in. Quarterly data is a 10-Q affair, so IFRS filers get annual periods.
332
+ Foreign filers report under IFRS in 20-F and 40-F filings with their own taxonomy; the same line items map to it, and `Period#currency` says what the figures are in, `nil` when the filing reports revenue and assets in no currency at all. Filers that tag several currencies, such as a Chinese company reporting in CNY with USD convenience figures, are read in the currency of their own statements. Quarterly data is a 10-Q affair, so IFRS filers get annual periods.
323
333
 
324
334
  ### Coverage
325
335
 
@@ -332,9 +342,13 @@ The concept map is measured, not guessed. `script/coverage` reads the latest ann
332
342
  | diluted EPS, income before tax, interest expense, depreciation and amortization, diluted shares | 5% to 7% |
333
343
  | shares outstanding, PP&E, capital expenditure | 11% to 14% |
334
344
 
335
- What is missing beyond that is mostly absent from the filing itself: inventory and cost of revenue at service and financial companies, buybacks and dividends at companies that do none, a classified balance sheet at banks and insurers. Companies with no annual filing yet, such as a fresh listing, raise `NoFinancialsError`.
345
+ What is missing beyond that is mostly absent from the filing itself: inventory and cost of revenue at service and financial companies, buybacks and dividends at companies that do none, a classified balance sheet at banks and insurers. Companies with no annual filing yet, such as a fresh listing, raise `NoFinancialsError` from `financials`.
346
+
347
+ Every error inherits `Fundamentalista::Error`. Provider errors are typed: `CompanyNotFoundError`, `UnauthorizedError`, `RateLimitError`, `ServerError`, each carrying the HTTP `response`. Transient failures are retried, and EDGAR requests are spaced to the SEC's rate limit.
348
+
349
+ ### Limitations
336
350
 
337
- Provider errors are typed: `CompanyNotFoundError`, `UnauthorizedError`, `RateLimitError`, `ServerError`, each carrying the HTTP `response`. Transient failures are retried, and EDGAR requests are spaced to the SEC's rate limit.
351
+ The figures are as filed. A stock split is restated only in filings made after it, so per share history before the split is in pre-split shares. Companies with several share classes tag the totals per class, so `diluted_shares` and `shares_outstanding` can be missing for them. A company in its first year of filing may label two quarters the same way, and one of them is dropped. Expenses a filer negates, such as a released credit provision, keep their sign.
338
352
 
339
353
  ### Caching
340
354
 
@@ -40,8 +40,9 @@ module Fundamentalista
40
40
 
41
41
  # Working capital net of cash and debt: receivables plus inventory minus
42
42
  # payables, the operating capital Greenblatt's return on capital uses.
43
+ # A company with no inventory line has none.
43
44
  def net_working_capital
44
- Decimal.subtract(Decimal.sum(receivables, inventory), payables)
45
+ Decimal.subtract(Decimal.sum(receivables, inventory || BigDecimal('0')), payables)
45
46
  end
46
47
  end
47
48
  end
@@ -19,9 +19,11 @@ module Fundamentalista
19
19
  new(**summed.to_h, **balances)
20
20
  end
21
21
 
22
- # Whether the company reports net interest income, the mark of a bank.
22
+ # Whether the company earns net interest income, the mark of a bank;
23
+ # an insurer or industrial tagging its net interest expense there is
24
+ # not one.
23
25
  def bank?
24
- !net_interest_income.nil? && !net_interest_income.zero?
26
+ !net_interest_income.nil? && net_interest_income.positive?
25
27
  end
26
28
 
27
29
  # Net interest income plus noninterest income, a bank's revenue.
@@ -2,9 +2,11 @@
2
2
 
3
3
  module Fundamentalista
4
4
  # One period of the cash flow statement. Outflows are positive amounts:
5
- # +capital_expenditure+ is what was spent, +dividends_paid+ what was paid.
5
+ # +capital_expenditure+ is what was spent, +dividends_paid+ what was paid,
6
+ # and +dividends_per_share+ what was declared on each share.
6
7
  class CashFlowStatement < Statement
7
- field :operating_cash_flow, :capital_expenditure, :free_cash_flow, :dividends_paid, :share_repurchases
8
+ field :operating_cash_flow, :capital_expenditure, :free_cash_flow, :dividends_paid, :dividends_per_share,
9
+ :share_repurchases
8
10
 
9
11
  # Free cash flow, derived from operating cash flow and capital
10
12
  # expenditure when not reported.
@@ -16,6 +16,7 @@ module Fundamentalista
16
16
  #
17
17
  class Company
18
18
  include Inspectable
19
+ include Serializable
19
20
 
20
21
  attr_reader :ticker, :name, :cik, :currency, :provider
21
22
 
@@ -34,9 +35,12 @@ module Fundamentalista
34
35
  end
35
36
 
36
37
  # Returns the Financials, newest first. +period+ is +:annual+ or
37
- # +:quarterly+ and +limit+ the number of periods.
38
+ # +:quarterly+ and +limit+ the number of periods. Raises
39
+ # NoFinancialsError when the provider has none, as with a fresh listing.
38
40
  def financials(period: :annual, limit: 5)
39
- @financials[[period, limit]] ||= provider.financials(self, period: period, limit: limit)
41
+ @financials[[period, limit]] ||= provider.financials(self, period: period, limit: limit).tap do |financials|
42
+ raise NoFinancialsError, "#{provider.class.slug} has no #{period} statements for #{ticker}" if financials.none?
43
+ end
40
44
  end
41
45
 
42
46
  # Returns the PriceHistory between +from+ and +to+. Raises
@@ -100,7 +104,8 @@ module Fundamentalista
100
104
  # Returns the Valuation of the latest annual period at +price+, or at
101
105
  # the provider's quote when no price is given, with the next fiscal
102
106
  # year's Estimate when the provider has one.
103
- def valuation(price: nil, period: latest_period)
107
+ def valuation(price: nil, period: nil)
108
+ period ||= latest_period
104
109
  Valuation.new(period, price ? Quote.new(price: price) : quote, estimate: estimate(after: period.ended_on))
105
110
  end
106
111
 
@@ -109,14 +114,17 @@ module Fundamentalista
109
114
  latest_period.ratios
110
115
  end
111
116
 
112
- # The latest annual Period. Raises NoFinancialsError when the provider
113
- # has no annual statements for the company, as with a fresh listing.
117
+ # The latest annual Period.
114
118
  def latest_period
115
- financials.latest || raise(NoFinancialsError, "#{provider.class.slug} has no annual statements for #{ticker}")
119
+ financials.latest
120
+ end
121
+
122
+ def to_h
123
+ { ticker: ticker, name: name, cik: cik, currency: currency, beta: beta, provider: provider.class.slug }
116
124
  end
117
125
 
118
126
  def inspect_attributes # :nodoc:
119
- { ticker: ticker, name: name, cik: cik, provider: provider.class.slug }
127
+ to_h.slice(:ticker, :name, :cik, :provider)
120
128
  end
121
129
  end
122
130
  end
@@ -3,12 +3,17 @@
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, on the latest fiscal year or, with +period: :ttm+, on
7
+ # the trailing twelve months. With a +currency+, amounts such as revenue
8
+ # or market cap are converted into it through Rates, while ratios and
9
+ # multiples stay as they are.
7
10
  #
8
11
  # peers = Fundamentalista.compare("AAPL", "MSFT", prices: { "AAPL" => 320, "MSFT" => 500 })
9
12
  # peers.table(:roe, :pe, :piotroski) # => {"AAPL" => {roe: ..., pe: ..., piotroski: 8}, ...}
10
13
  # peers.rank(:pe, ascending: true) # => ["MSFT", "AAPL"]
11
14
  # peers.magic_formula # => ["AAPL", "MSFT"]
15
+ # Fundamentalista.compare("AAPL", "MSFT", period: :ttm).values(:roe)
16
+ # Fundamentalista.compare("AAPL", "NVO", currency: "USD").values(:revenue)
12
17
  #
13
18
  class Comparison
14
19
  include Enumerable
@@ -20,9 +25,30 @@ module Fundamentalista
20
25
 
21
26
  attr_reader :companies
22
27
 
23
- def initialize(companies, prices: {})
28
+ # +:annual+ for the latest fiscal year, +:ttm+ for the trailing twelve
29
+ # months.
30
+ attr_reader :period
31
+
32
+ # The currency amounts are converted into, or +nil+ to leave each
33
+ # company in its own.
34
+ attr_reader :currency
35
+
36
+ def initialize(companies, prices: {}, period: :annual, currency: nil, rates: nil)
24
37
  @companies = companies.to_h { |company| [company.ticker, company] }
25
38
  @prices = prices.transform_keys(&:to_s)
39
+ @period = period
40
+ @currency = currency
41
+ @rates = rates
42
+ end
43
+
44
+ # The Rates in use, fetched from the first company's provider when
45
+ # none were given.
46
+ def rates
47
+ return nil unless currency
48
+
49
+ @rates ||= companies.values.first.provider.rates(companies.values.map do |company|
50
+ company.latest_period.currency
51
+ end, to: currency)
26
52
  end
27
53
 
28
54
  def each(&)
@@ -64,13 +90,25 @@ module Fundamentalista
64
90
  private
65
91
 
66
92
  def metric(company, name)
67
- period = company.latest_period
93
+ period = period_of(company)
68
94
  valuation = valuation_of(company, period)
69
95
  return period.ratios.public_send(name) if Ratios::ALL.include?(name)
70
- return valuation&.public_send(name) if Valuation::ALL.include?(name)
96
+ if Valuation::ALL.include?(name)
97
+ return converted(valuation&.public_send(name), period, amount: Valuation::AMOUNTS.include?(name))
98
+ end
71
99
  return SCORES[name].call(period, valuation) if SCORES.key?(name)
72
100
 
73
- period.metric(name)
101
+ converted(period.metric(name), period, amount: !name.end_with?('shares_outstanding', 'diluted_shares'))
102
+ end
103
+
104
+ def period_of(company)
105
+ period == :ttm ? company.ttm : company.latest_period
106
+ end
107
+
108
+ def converted(value, period, amount:)
109
+ return value if value.nil? || !amount || currency.nil? || period.currency == currency
110
+
111
+ rates.convert(value, from: period.currency)
74
112
  end
75
113
 
76
114
  def valuation_of(company, period)
@@ -24,7 +24,9 @@ module Fundamentalista
24
24
  @terminal_growth = Decimal.wrap(terminal_growth)
25
25
  @years = years
26
26
  @fade = fade
27
+ raise ArgumentError, 'cash_flow, growth and discount_rate are required' if [@cash_flow, @growth, @discount_rate].any?(nil)
27
28
  raise ArgumentError, 'discount_rate must exceed terminal_growth' if @discount_rate <= @terminal_growth
29
+ raise ArgumentError, 'years must be at least 1' if years < 1
28
30
  end
29
31
 
30
32
  # Whether growth fades towards the terminal rate over the projection.
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fundamentalista
4
- # Base class for errors raised while talking to a data provider. Carries
5
- # the HTTP response when there is one.
4
+ # Base class of every error the gem raises, so one rescue catches them
5
+ # all. Errors raised while talking to a data provider carry the HTTP
6
+ # response when there is one.
6
7
  class Error < StandardError
7
8
  attr_reader :response
8
9
 
@@ -12,12 +13,12 @@ module Fundamentalista
12
13
  end
13
14
  end
14
15
 
15
- class ConfigurationError < StandardError; end
16
+ class ConfigurationError < Error; end
16
17
  class CompanyNotFoundError < Error; end
17
18
  class UnauthorizedError < Error; end
18
19
  class RateLimitError < Error; end
19
20
  class ServerError < Error; end
20
- class UnsupportedPeriodError < StandardError; end
21
- class NoFinancialsError < StandardError; end
22
- class QuoteUnavailableError < StandardError; end
21
+ class UnsupportedPeriodError < Error; end
22
+ class NoFinancialsError < Error; end
23
+ class QuoteUnavailableError < Error; end
23
24
  end
@@ -43,5 +43,13 @@ module Fundamentalista
43
43
  def tax_rate
44
44
  Decimal.ratio(income_tax, income_before_tax)
45
45
  end
46
+
47
+ # Net operating profit after tax: EBIT taxed at the effective rate,
48
+ # held between 0 and 100% so a tax benefit or a loss year does not
49
+ # inflate it.
50
+ def nopat
51
+ rate = tax_rate
52
+ ebit && rate && (ebit * (1 - rate.clamp(0, 1)))
53
+ end
46
54
  end
47
55
  end
@@ -96,11 +96,11 @@ module Fundamentalista
96
96
  earlier && Decimal.cagr(earlier.metric(metric), self.metric(metric), 1)
97
97
  end
98
98
 
99
- # Returns the period and its statements as a Hash.
99
+ # Returns the period, its statements and its sources as a Hash.
100
100
  def to_h
101
101
  { fiscal_year: fiscal_year, quarter: quarter, type: type, ended_on: ended_on, currency: currency,
102
102
  income: income.to_h, balance: balance.to_h, cash_flow: cash_flow.to_h, banking: banking.to_h,
103
- insurance: insurance.to_h }
103
+ insurance: insurance.to_h, sources: sources.transform_values(&:to_h) }
104
104
  end
105
105
 
106
106
  # The days the period spans, for turning turnover into days.
@@ -137,19 +137,20 @@ module Fundamentalista
137
137
  # Returns the line item +name+ from whichever statement reports it, or
138
138
  # +nil+. Derived items such as +free_cash_flow+ count, so does
139
139
  # +owner_earnings+, and any of them followed by +_per_share+ is divided
140
- # by the shares outstanding.
140
+ # by the shares outstanding, unless a statement reports it per share
141
+ # already, as with +dividends_per_share+.
141
142
  #
142
143
  # period.metric(:revenue) # => 0.416161e12
143
144
  # period.metric(:free_cash_flow_per_share) # => 0.66854e1
144
145
  #
145
146
  def metric(name)
146
147
  return owner_earnings if name == :owner_earnings
147
- return per_share(name.to_s.delete_suffix('_per_share').to_sym) if name.end_with?('_per_share')
148
148
 
149
149
  statement = [income, balance, cash_flow, banking, insurance].find { |candidate| candidate.respond_to?(name) }
150
- raise ArgumentError, "Unknown metric #{name.inspect}" unless statement
150
+ return statement.public_send(name) if statement
151
+ return per_share(name.to_s.delete_suffix('_per_share').to_sym) if name.end_with?('_per_share')
151
152
 
152
- statement.public_send(name)
153
+ raise ArgumentError, "Unknown metric #{name.inspect}"
153
154
  end
154
155
 
155
156
  # Returns +name+ divided by the shares outstanding at the end of the
@@ -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: {})
@@ -6,33 +6,40 @@ module Fundamentalista
6
6
  # Reads a companyfacts document. Every concept carries every value
7
7
  # ever filed, including restatements, so a line item is looked up by
8
8
  # the period it describes and the most recently filed value wins,
9
- # while the fiscal year label comes from the original filing.
9
+ # while the fiscal year label comes from the original filing. Only
10
+ # the units of the reporting currency count, so a filer that tags
11
+ # figures in two currencies is read in one.
10
12
  #
11
13
  # Quarterly flows are not always reported as such: cash flow
12
14
  # statements in 10-Q filings are year to date, and the fourth quarter
13
15
  # only exists as the annual figure. A quarter is read directly when a
14
16
  # three month value exists, and derived otherwise as the difference
15
17
  # between two year to date values that share a fiscal year start.
18
+ # Weighted average share counts do not subtract, so the year to date
19
+ # average stands in for the quarter, and per share earnings are left
20
+ # for the statement to compute from it.
16
21
  class Facts # :nodoc:
17
22
  ANNUAL = (350..380)
18
23
  QUARTER = (80..100)
19
24
  ANNUAL_FORMS = %w[10-K 20-F 40-F].freeze
20
25
  QUARTERLY_FORMS = %w[10-K 10-Q].freeze
21
- QUARTERS = { 'Q1' => 1, 'Q2' => 2, 'Q3' => 3, 'FY' => 4 }.freeze
26
+ QUARTERS = { 'Q1' => 1, 'Q2' => 2, 'Q3' => 3, 'Q4' => 4, 'FY' => 4 }.freeze
22
27
  TAXONOMIES = %w[us-gaap ifrs-full dei].freeze
28
+ CURRENCY = /\A[A-Z]{3}\b/
23
29
 
24
30
  def initialize(document)
25
31
  facts = document.fetch('facts', {})
26
32
  @facts = TAXONOMIES.map { |taxonomy| facts.fetch(taxonomy, {}) }
27
33
  end
28
34
 
29
- # The ISO code of the currency revenue is reported in.
35
+ # The ISO code of the currency revenue, or failing that the balance
36
+ # sheet, is reported in; +nil+ when the document says nothing.
30
37
  def currency
31
- Tags::INCOME[:revenue].each do |concept|
32
- unit = units(concept).keys.find { |candidate| candidate.match?(/\A[A-Z]{3}\z/) }
33
- return unit if unit
34
- end
35
- 'USD'
38
+ return @currency if defined?(@currency)
39
+
40
+ @currency = (Tags::INCOME[:revenue] + ['Assets']).lazy.filter_map do |concept|
41
+ units(concept).keys.find { |unit| unit.match?(/\A[A-Z]{3}\z/) }
42
+ end.first
36
43
  end
37
44
 
38
45
  # The fiscal years on file, newest first, as [year, ended_on] pairs.
@@ -54,7 +61,7 @@ module Fundamentalista
54
61
  found = first_reported(alternatives) do |concept|
55
62
  instant?(item) ? instant(concept, ended_on, ANNUAL_FORMS) : yearly(concept, ended_on)
56
63
  end
57
- [item, found || cover(item, year)]
64
+ [item, magnitude(item, found || cover(item, year))]
58
65
  end
59
66
  end
60
67
 
@@ -63,14 +70,16 @@ module Fundamentalista
63
70
  # [value, Source] pairs keyed by item.
64
71
  def quarterly(tags, ended_on, previous)
65
72
  tags.to_h do |item, alternatives|
66
- [item, first_reported(alternatives) do |concept|
67
- instant?(item) ? instant(concept, ended_on) : three_months(concept, ended_on, previous)
68
- end]
73
+ [item, magnitude(item, first_reported(alternatives) { |concept| quarter(item, concept, ended_on, previous) })]
69
74
  end
70
75
  end
71
76
 
72
77
  private
73
78
 
79
+ def magnitude(item, found)
80
+ found && Tags::OUTFLOWS.include?(item) ? [found.first.abs, found.last] : found
81
+ end
82
+
74
83
  def cover(item, year)
75
84
  concept = Tags::COVER[item]
76
85
  return nil unless concept && year
@@ -125,16 +134,24 @@ module Fundamentalista
125
134
  end)
126
135
  end
127
136
 
128
- def three_months(concept, ended_on, previous)
137
+ def quarter(item, concept, ended_on, previous)
138
+ return instant(concept, ended_on) if instant?(item)
139
+
129
140
  spans = ending(concept, ended_on).select { |entry| entry['start'] }
130
- latest(concept, spans.select do |entry|
131
- QUARTER.cover?(duration_of(entry))
132
- end) || derived_quarter(concept, spans, previous)
141
+ latest(concept, spans.select { |entry| QUARTER.cover?(duration_of(entry)) }) ||
142
+ from_year_to_date(item, concept, spans, previous)
133
143
  end
134
144
 
135
- def derived_quarter(concept, spans, previous)
145
+ def from_year_to_date(item, concept, spans, previous)
136
146
  year_to_date = spans.max_by { |entry| [duration_of(entry), entry['filed']] }
137
- return nil unless year_to_date && previous
147
+ return nil if year_to_date.nil? || Tags::PER_SHARE.include?(item)
148
+ return [year_to_date['val'], source_of(concept, year_to_date, :year_to_date)] if Tags::AVERAGES.include?(item)
149
+
150
+ derived_quarter(concept, year_to_date, previous)
151
+ end
152
+
153
+ def derived_quarter(concept, year_to_date, previous)
154
+ return nil unless previous
138
155
 
139
156
  earlier = latest(concept, ending(concept, previous).select { |entry| entry['start'] == year_to_date['start'] })
140
157
  earlier && [year_to_date['val'] - earlier.first, source_of(concept, year_to_date, :derived)]
@@ -160,7 +177,7 @@ module Fundamentalista
160
177
  end
161
178
 
162
179
  def entries(concept)
163
- units(concept).values.flatten
180
+ units(concept).select { |unit, _| !unit.match?(CURRENCY) || unit.start_with?(currency.to_s) }.values.flatten
164
181
  end
165
182
 
166
183
  def units(concept)
@@ -13,9 +13,9 @@ module Fundamentalista
13
13
  # concepts a date.
14
14
  module Tags
15
15
  INCOME = {
16
- revenue: %w[RevenueFromContractWithCustomerExcludingAssessedTax Revenues SalesRevenueNet
16
+ revenue: %w[Revenues RevenueFromContractWithCustomerExcludingAssessedTax SalesRevenueNet
17
17
  RevenueFromContractWithCustomerIncludingAssessedTax RevenuesNetOfInterestExpense Revenue
18
- RevenueFromContractsWithCustomers],
18
+ RevenueFromContractsWithCustomers RevenueFromSaleOfGoods],
19
19
  cost_of_revenue: %w[CostOfRevenue CostOfGoodsAndServicesSold CostOfGoodsSold CostOfSales
20
20
  CostOfGoodsAndServiceExcludingDepreciationDepletionAndAmortization],
21
21
  gross_profit: %w[GrossProfit],
@@ -34,7 +34,7 @@ module Fundamentalista
34
34
  net_income: %w[NetIncomeLoss ProfitLossAttributableToOwnersOfParent ProfitLoss],
35
35
  depreciation_amortization: ['DepreciationDepletionAndAmortization', 'DepreciationAndAmortization',
36
36
  'DepreciationAmortizationAndAccretionNet', 'DepreciationAmortizationAndOther',
37
- 'DepreciationAndAmortisationExpense',
37
+ 'DepreciationAndAmortisationExpense', 'AdjustmentsForDepreciationAndAmortisationExpense',
38
38
  %w[DepreciationPropertyPlantAndEquipment AmortisationIntangibleAssetsOtherThanGoodwill],
39
39
  %w[Depreciation AmortizationOfIntangibleAssets], 'Depreciation'],
40
40
  eps_diluted: %w[EarningsPerShareDiluted DilutedEarningsLossPerShare],
@@ -66,7 +66,7 @@ module Fundamentalista
66
66
  'ShorttermBorrowings', 'CurrentBorrowings'],
67
67
  long_term_debt: %w[LongTermDebtNoncurrent LongTermDebtAndCapitalLeaseObligations LongTermDebt LongTermNotesPayable
68
68
  LongTermNotesAndLoans LongtermBorrowings NoncurrentBorrowings
69
- LongTermDebtAndCapitalLeaseObligationsIncludingCurrentMaturities],
69
+ LongTermDebtAndCapitalLeaseObligationsIncludingCurrentMaturities Borrowings],
70
70
  equity: %w[StockholdersEquity StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest
71
71
  EquityAttributableToOwnersOfParent Equity],
72
72
  minority_interest: %w[MinorityInterest NoncontrollingInterests],
@@ -88,6 +88,7 @@ module Fundamentalista
88
88
  dividends_paid: %w[PaymentsOfDividends PaymentsOfDividendsCommonStock PaymentsOfOrdinaryDividends
89
89
  DividendsCommonStockCash DividendsPaidClassifiedAsFinancingActivities
90
90
  DividendsPaidToEquityHoldersOfParentClassifiedAsFinancingActivities DividendsPaid],
91
+ dividends_per_share: %w[CommonStockDividendsPerShareDeclared CommonStockDividendsPerShareCashPaid],
91
92
  share_repurchases: %w[PaymentsForRepurchaseOfCommonStock PaymentsForRepurchaseOfEquity
92
93
  TreasuryStockValueAcquiredCostMethod PaymentsForPurchaseOfTreasuryShares]
93
94
  }.freeze
@@ -96,7 +97,9 @@ module Fundamentalista
96
97
  net_interest_income: %w[InterestIncomeExpenseNet],
97
98
  interest_income: %w[InterestAndDividendIncomeOperating InterestIncome InterestAndFeeIncomeLoansAndLeases],
98
99
  provision_for_credit_losses: %w[ProvisionForLoanLeaseAndOtherLosses ProvisionForLoanAndLeaseLosses
99
- ProvisionForCreditLosses ProvisionForLoanLossesExpensed],
100
+ ProvisionForCreditLosses ProvisionForLoanLossesExpensed
101
+ FinancingReceivableExcludingAccruedInterestCreditLossExpenseReversal
102
+ FinancingReceivableCreditLossExpenseReversal],
100
103
  noninterest_income: %w[NoninterestIncome],
101
104
  noninterest_expense: %w[NoninterestExpense],
102
105
  loans: %w[FinancingReceivableExcludingAccruedInterestAfterAllowanceForCreditLoss
@@ -119,14 +122,23 @@ module Fundamentalista
119
122
  benefits_and_expenses: %w[BenefitsLossesAndExpenses],
120
123
  investment_income: %w[NetInvestmentIncome GrossInvestmentIncomeOperating],
121
124
  investments: %w[Investments],
122
- reserves: %w[LiabilityForFuturePolicyBenefitsAndUnpaidClaimsAndClaimsAdjustmentExpense
123
- LiabilityForClaimsAndClaimsAdjustmentExpense LiabilityForFuturePolicyBenefits],
125
+ reserves: [%w[LiabilityForClaimsAndClaimsAdjustmentExpense LiabilityForFuturePolicyBenefits],
126
+ 'LiabilityForFuturePolicyBenefitsAndUnpaidClaimsAndClaimsAdjustmentExpense'],
124
127
  unearned_premiums: %w[UnearnedPremiums],
125
- premiums_receivable: %w[PremiumsReceivableAtCarryingValue],
126
- reinsurance_recoverables: %w[ReinsuranceRecoverablesOnPaidAndUnpaidLosses]
128
+ premiums_receivable: %w[PremiumsReceivableAtCarryingValue PremiumsAndOtherReceivablesNet],
129
+ reinsurance_recoverables: %w[ReinsuranceRecoverablesOnPaidAndUnpaidLosses
130
+ ReinsuranceRecoverableForUnpaidClaimsAndClaimsAdjustments]
127
131
  }.freeze
128
132
 
129
133
  INSTANT = (BALANCE.keys + Banking::INSTANT + Insurance::INSTANT).freeze
134
+
135
+ # Expenses and outflows are magnitudes, whichever sign the filer gave them.
136
+ OUTFLOWS = %i[interest_expense capital_expenditure dividends_paid share_repurchases].freeze
137
+
138
+ # Weighted averages and per share figures, which no fourth quarter
139
+ # can be derived for by subtracting year to date values.
140
+ AVERAGES = %i[diluted_shares].freeze
141
+ PER_SHARE = %i[eps_diluted].freeze
130
142
  end
131
143
  end
132
144
  end
@@ -15,10 +15,15 @@ module Fundamentalista
15
15
  TICKERS_URL = 'https://www.sec.gov/files/company_tickers.json'
16
16
  FACTS_URL = 'https://data.sec.gov/api/xbrl/companyfacts/CIK%010d.json'
17
17
 
18
+ # Resolves a ticker, or a CIK given as digits for registrants the
19
+ # ticker list no longer carries, such as the predecessor of a company
20
+ # that reincorporated.
18
21
  def company(ticker)
19
22
  symbol = ticker.to_s.upcase.tr('.', '-')
20
- entry = tickers[symbol] || raise(CompanyNotFoundError, "EDGAR lists no company under #{ticker.inspect}")
21
- Company.new(ticker: symbol, name: entry['title'], cik: entry['cik_str'], provider: self)
23
+ entry = symbol.match?(/\A\d+\z/) ? registrant(symbol.to_i) : tickers[symbol]
24
+ raise CompanyNotFoundError, "EDGAR lists no company under #{ticker.inspect}" unless entry
25
+
26
+ Company.new(ticker: entry['ticker'], name: entry['title'], cik: entry['cik_str'], provider: self)
22
27
  end
23
28
 
24
29
  def financials(company, period: :annual, limit: 5)
@@ -48,6 +53,10 @@ module Fundamentalista
48
53
  raise UnsupportedPeriodError, "EDGAR's company facts carry no segments for #{company.ticker}"
49
54
  end
50
55
 
56
+ def rates(_currencies, **)
57
+ raise QuoteUnavailableError, 'EDGAR has no exchange rates; pass Rates'
58
+ end
59
+
51
60
  private
52
61
 
53
62
  STATEMENTS = { income: [IncomeStatement, Tags::INCOME], balance: [BalanceSheet, Tags::BALANCE],
@@ -78,8 +87,13 @@ module Fundamentalista
78
87
  sources: sources, **statements)
79
88
  end
80
89
 
90
+ def registrant(cik)
91
+ tickers.values.find { |entry| entry['cik_str'] == cik } || { 'ticker' => cik.to_s, 'cik_str' => cik }
92
+ end
93
+
81
94
  def company_facts(company)
82
- get(format(FACTS_URL, company.cik), headers: headers)
95
+ @documents ||= {}
96
+ @documents[company.cik] ||= get(format(FACTS_URL, company.cik), headers: headers)
83
97
  rescue CompanyNotFoundError => e
84
98
  raise CompanyNotFoundError.new("EDGAR has no XBRL facts for #{company.ticker}; it may not file financial statements",
85
99
  response: e.response)
@@ -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
@@ -59,17 +59,18 @@ module Fundamentalista
59
59
  Decimal.ratio(income.net_income, average(:total_assets))
60
60
  end
61
61
 
62
- # Return on invested capital: after tax operating income over average
63
- # debt plus equity.
62
+ # Return on invested capital: net operating profit after tax over
63
+ # average debt plus equity.
64
64
  def roic
65
- nopat = income.operating_income && income.tax_rate && (income.operating_income * (1 - income.tax_rate))
66
- Decimal.ratio(nopat, Decimal.sum(average(:total_debt), average(:equity)))
65
+ Decimal.ratio(income.nopat, Decimal.sum(average(:total_debt), average(:equity)))
67
66
  end
68
67
 
69
68
  # Greenblatt's return on capital: EBIT over net working capital plus net
70
- # fixed assets.
69
+ # fixed assets. Negative working capital is financing, not capital, so
70
+ # it counts as zero, and there is no return on capital that is not there.
71
71
  def return_on_capital
72
- Decimal.ratio(income.ebit, Decimal.sum(average(:net_working_capital), average(:ppe)))
72
+ capital = Decimal.sum(average(:net_working_capital)&.clamp(0..), average(:ppe))
73
+ capital&.positive? ? Decimal.ratio(income.ebit, capital) : nil
73
74
  end
74
75
 
75
76
  def current_ratio
@@ -2,9 +2,9 @@
2
2
 
3
3
  module Fundamentalista
4
4
  # Turns a value object into plain data. #to_h keeps every figure a
5
- # BigDecimal; #as_json turns figures into Floats and dates into ISO
6
- # strings, which is what JSON consumers expect, so +to_json+ and Rails'
7
- # +render json:+ work out of the box.
5
+ # BigDecimal under symbol keys; #as_json turns figures into Floats, dates
6
+ # into ISO strings and keys into strings, which is what JSON consumers
7
+ # and Rails expect, so +to_json+ and +render json:+ work out of the box.
8
8
  module Serializable
9
9
  def as_json(*)
10
10
  Serializable.plain(to_h)
@@ -16,7 +16,7 @@ module Fundamentalista
16
16
 
17
17
  def self.plain(value) # :nodoc:
18
18
  case value
19
- when Hash then value.transform_values { |inner| plain(inner) }
19
+ when Hash then value.to_h { |key, inner| [key.to_s, plain(inner)] }
20
20
  when Array then value.map { |inner| plain(inner) }
21
21
  when Serializable then value.as_json
22
22
  else scalar(value)
@@ -3,9 +3,11 @@
3
3
  module Fundamentalista
4
4
  # Where a line item came from: the XBRL concept, the filing that
5
5
  # reported it, and whether the figure was reported as is, summed from
6
- # split concepts, or derived from year to date values.
6
+ # split concepts, derived from year to date values, or is the year to
7
+ # date average standing in for a quarter.
7
8
  class Source
8
9
  include Inspectable
10
+ include Serializable
9
11
 
10
12
  attr_reader :concept, :form, :filed, :derivation
11
13
 
@@ -20,8 +22,10 @@ module Fundamentalista
20
22
  derivation == :reported
21
23
  end
22
24
 
23
- def inspect_attributes # :nodoc:
25
+ def to_h
24
26
  { concept: concept, form: form, filed: filed, derivation: derivation }
25
27
  end
28
+
29
+ alias inspect_attributes to_h
26
30
  end
27
31
  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
 
@@ -101,8 +102,10 @@ module Fundamentalista
101
102
  Decimal.ratio(cash_flow.free_cash_flow, market_cap)
102
103
  end
103
104
 
105
+ # Dividends declared per share over the price, or dividends paid over
106
+ # market capitalization when the filing has no per share figure.
104
107
  def dividend_yield
105
- Decimal.ratio(cash_flow.dividends_paid, market_cap)
108
+ Decimal.ratio(cash_flow.dividends_per_share, price) || Decimal.ratio(cash_flow.dividends_paid, market_cap)
106
109
  end
107
110
 
108
111
  # Dividends plus buybacks over market capitalization.
@@ -134,10 +137,12 @@ module Fundamentalista
134
137
  period.altman_z(market_cap: market_cap)
135
138
  end
136
139
 
137
- # The DCF value per share of the period's free cash flow, net of debt.
138
- # Takes the same keywords as DCF.
139
- def intrinsic_value(**options)
140
- flow = cash_flow.free_cash_flow
140
+ # The DCF value per share of the period's free cash flow, or of the
141
+ # normalized +cash_flow+ you pass, net of debt. Takes the same keywords
142
+ # as DCF. +nil+ when the cash flow is not positive: there is nothing to
143
+ # discount, and the growth to apply to a loss is not a rate.
144
+ def intrinsic_value(cash_flow: self.cash_flow.free_cash_flow, **options)
145
+ flow = Decimal.wrap(cash_flow)
141
146
  return nil if flow.nil? || flow <= 0 || shares_outstanding.nil?
142
147
 
143
148
  equity = DCF.new(cash_flow: flow, **options).value - (balance.net_debt || 0)
@@ -184,17 +189,17 @@ module Fundamentalista
184
189
  # never grew, net of debt. What the company is worth with no growth
185
190
  # priced in; compare it with the price to see how much growth is.
186
191
  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?
192
+ capitalized = Decimal.ratio(income.nopat, Decimal.wrap(discount_rate))
193
+ return nil if capitalized.nil? || shares_outstanding.nil?
189
194
 
190
- equity = (nopat / Decimal.wrap(discount_rate)) - (balance.net_debt || 0)
191
- equity / shares_outstanding
195
+ (capitalized - (balance.net_debt || 0)) / shares_outstanding
192
196
  end
193
197
 
194
198
  # 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.
199
+ # value of each coming year's earnings above the +cost_of_equity+
200
+ # charge on the book value opening it, with earnings grown at +growth+
201
+ # for +years+ and retained at the payout ratio, and none after. Rewards
202
+ # returns on equity above the cost of equity, and nothing else.
198
203
  def residual_income_value(cost_of_equity:, growth: BigDecimal('0'), years: 10)
199
204
  book = balance.equity
200
205
  earnings = income.net_income
@@ -203,9 +208,8 @@ module Fundamentalista
203
208
  rate = Decimal.wrap(cost_of_equity)
204
209
  value = book
205
210
  (1..years).each do |year|
206
- residual = earnings - (rate * book)
207
- value += residual / ((1 + rate)**year)
208
211
  earnings *= 1 + Decimal.wrap(growth)
212
+ value += (earnings - (rate * book)) / ((1 + rate)**year)
209
213
  book += earnings * (1 - (payout_ratio || 0))
210
214
  end
211
215
  value / shares_outstanding
@@ -229,7 +233,7 @@ module Fundamentalista
229
233
  end
230
234
 
231
235
  def to_h
232
- ALL.to_h { |name| [name, public_send(name)] }
236
+ { price: price, **ALL.to_h { |name| [name, public_send(name)] } }
233
237
  end
234
238
 
235
239
  def inspect_attributes # :nodoc:
@@ -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.6.0'
6
6
  end
@@ -36,9 +36,11 @@ 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, on the latest fiscal year or the trailing twelve
40
+ # months with +period: :ttm+, with amounts converted into +currency+
41
+ # through the provider's Rates or the +rates+ you pass. Tickers the
42
+ # provider cannot resolve are logged and left out.
43
+ def compare(*tickers, prices: {}, period: :annual, currency: nil, rates: nil, provider: config.default_provider)
42
44
  source = provider(provider)
43
45
  companies = tickers.flatten.filter_map do |ticker|
44
46
  source.company(ticker)
@@ -46,7 +48,7 @@ module Fundamentalista
46
48
  logger.warn("Skipping #{ticker}: #{e.message}")
47
49
  nil
48
50
  end
49
- Comparison.new(companies, prices: prices)
51
+ Comparison.new(companies, prices: prices, period: period, currency: currency, rates: rates)
50
52
  end
51
53
 
52
54
  # 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.6.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