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 +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +22 -8
- data/lib/fundamentalista/balance_sheet.rb +2 -1
- data/lib/fundamentalista/banking.rb +4 -2
- data/lib/fundamentalista/cash_flow_statement.rb +4 -2
- data/lib/fundamentalista/company.rb +15 -7
- data/lib/fundamentalista/comparison.rb +43 -5
- data/lib/fundamentalista/dcf.rb +2 -0
- data/lib/fundamentalista/error.rb +7 -6
- data/lib/fundamentalista/income_statement.rb +8 -0
- data/lib/fundamentalista/period.rb +7 -6
- data/lib/fundamentalista/provider.rb +6 -2
- data/lib/fundamentalista/providers/edgar/facts.rb +36 -19
- data/lib/fundamentalista/providers/edgar/tags.rb +21 -9
- data/lib/fundamentalista/providers/edgar.rb +17 -3
- data/lib/fundamentalista/providers/fmp.rb +10 -1
- data/lib/fundamentalista/rates.rb +43 -0
- data/lib/fundamentalista/ratios.rb +7 -6
- data/lib/fundamentalista/serializable.rb +4 -4
- data/lib/fundamentalista/source.rb +6 -2
- data/lib/fundamentalista/valuation.rb +20 -16
- data/lib/fundamentalista/version.rb +1 -1
- data/lib/fundamentalista.rb +6 -4
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eeff95349adcf9312850781f5792f603793c0a104cd49a370d7895eddb9bf334
|
|
4
|
+
data.tar.gz: 1268d8bd7117f7a874de9d8fd65b51f13d70d2b5b1aae5b106e0426b20b1f852
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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? &&
|
|
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, :
|
|
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:
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
data/lib/fundamentalista/dcf.rb
CHANGED
|
@@ -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
|
|
5
|
-
#
|
|
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 <
|
|
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 <
|
|
21
|
-
class NoFinancialsError <
|
|
22
|
-
class QuoteUnavailableError <
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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)
|
|
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
|
|
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
|
|
131
|
-
|
|
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
|
|
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
|
|
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
|
|
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[
|
|
123
|
-
|
|
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 =
|
|
21
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
6
|
-
# strings, which is what JSON consumers
|
|
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.
|
|
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,
|
|
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
|
|
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
|
-
|
|
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,
|
|
138
|
-
# Takes the same keywords
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
188
|
-
return nil if
|
|
192
|
+
capitalized = Decimal.ratio(income.nopat, Decimal.wrap(discount_rate))
|
|
193
|
+
return nil if capitalized.nil? || shares_outstanding.nil?
|
|
189
194
|
|
|
190
|
-
|
|
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
|
|
196
|
-
#
|
|
197
|
-
#
|
|
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:
|
data/lib/fundamentalista.rb
CHANGED
|
@@ -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
|
|
40
|
-
#
|
|
41
|
-
|
|
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
|
+
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
|