fundamentalista 0.5.0 → 0.7.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 +22 -0
- data/README.md +34 -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 +40 -8
- data/lib/fundamentalista/comparison.rb +16 -5
- data/lib/fundamentalista/configuration.rb +2 -1
- data/lib/fundamentalista/dcf.rb +2 -0
- data/lib/fundamentalista/earnings_release.rb +40 -0
- data/lib/fundamentalista/error.rb +7 -6
- data/lib/fundamentalista/filing.rb +36 -0
- data/lib/fundamentalista/income_statement.rb +8 -0
- data/lib/fundamentalista/metric.rb +80 -0
- data/lib/fundamentalista/period.rb +7 -6
- data/lib/fundamentalista/provider.rb +12 -3
- data/lib/fundamentalista/providers/chain.rb +48 -0
- data/lib/fundamentalista/providers/edgar/facts.rb +52 -20
- data/lib/fundamentalista/providers/edgar/tags.rb +21 -9
- data/lib/fundamentalista/providers/edgar.rb +38 -8
- data/lib/fundamentalista/providers/fmp.rb +13 -3
- 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 +18 -15
- data/lib/fundamentalista/version.rb +1 -1
- data/lib/fundamentalista.rb +23 -8
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 157c2e2b5398a2feb0e9c1d80b76b8273909a6d0e7fbd038929d647bb3b3d5e2
|
|
4
|
+
data.tar.gz: e4a34806e3588dcc4afdb41bf287523fc75500e1d74c13fb4aed7c477690bf6e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c9087d1aa92bd0d94baba9181ec26a8d598f5161af28aaa8e587cc2f8a5d7ca474ac056386c062a1aeac9eb9fb33fee27b81f4b8444886180a608b0b6fd4b677
|
|
7
|
+
data.tar.gz: e2e157259a7ce3a6768751599749dfc668b1be23445d9b1a453f459e5c70e01babcf06d87089f66d581572a987e5733103f7981070bb19c7af156f59308f7374
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
- Statements as they stood on a date with `financials(as_of:)`, before later filings and restatements, from EDGAR.
|
|
6
|
+
- `Company#filings` from EDGAR's submissions index, as `Filing` objects with the period reported, the document URL and the lag between the two.
|
|
7
|
+
- Earnings releases from FMP as `EarningsRelease`, with `Company#next_earnings` and the EPS surprise.
|
|
8
|
+
- A `Providers::Chain` behind a list of providers, so `provider: [:edgar, :fmp]` reads EDGAR for what it files and FMP for the rest, such as the quarters of a 20-F filer.
|
|
9
|
+
- `Fundamentalista.metrics`, a catalogue of every line item a period resolves, with statement, unit, derivation and whether it is a balance or a flow.
|
|
10
|
+
- The reporting currency comes from the latest filing, so a company that changed currencies is read in the new one.
|
|
11
|
+
|
|
12
|
+
## 0.6.0
|
|
13
|
+
|
|
14
|
+
- 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`.
|
|
15
|
+
- 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.
|
|
16
|
+
- 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.
|
|
17
|
+
- Expenses and outflows as magnitudes whichever sign the filer gave them; `bank?` requires positive net interest income.
|
|
18
|
+
- Dividends per share on the cash flow statement, read from the filing before it is derived.
|
|
19
|
+
- 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.
|
|
20
|
+
- `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.
|
|
21
|
+
- `Company#financials` raises `NoFinancialsError` when a filer has no statements, and every error inherits `Fundamentalista::Error`.
|
|
22
|
+
- `Company` and `Source` serialize, `Period#to_h` carries its sources, `Valuation#to_h` carries the price, and `as_json` returns string keys.
|
|
23
|
+
- Companies looked up by CIK, comparisons over trailing twelve months with `period: :ttm`, and one company facts document per company per provider.
|
|
24
|
+
|
|
3
25
|
## 0.5.0
|
|
4
26
|
|
|
5
27
|
- Exchange `Rates` from FMP or your own, and comparisons in one currency that convert amounts and leave ratios alone.
|
data/README.md
CHANGED
|
@@ -51,8 +51,12 @@ 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
|
|
55
|
+
ypf = Fundamentalista.company("YPF", provider: [:edgar, :fmp]) # EDGAR first, FMP for what it lacks
|
|
54
56
|
```
|
|
55
57
|
|
|
58
|
+
A list of providers is a chain: the company comes from the first one that lists it, and every question goes to the first one with an answer, so a 20-F filer gets its annual statements with provenance from EDGAR and its quarters from FMP. `config.default_provider` takes a list too.
|
|
59
|
+
|
|
56
60
|
`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.
|
|
57
61
|
|
|
58
62
|
```ruby
|
|
@@ -72,6 +76,8 @@ financials.series(:dividends_paid_per_share) # any of them per share
|
|
|
72
76
|
financials.growth(:free_cash_flow_per_share, years: 5)
|
|
73
77
|
```
|
|
74
78
|
|
|
79
|
+
`Fundamentalista.metrics` catalogues every line item `metric` resolves, as `Metric` objects with the statement, the unit (`:currency`, `:shares`, `:per_share` or `:rate`), whether the item is reported or derived, and whether it is a balance at a date or a flow over the period, so an application can offer only what the gem can fill in.
|
|
80
|
+
|
|
75
81
|
### Quarters and the trailing twelve months
|
|
76
82
|
|
|
77
83
|
Annual figures go stale for up to a year, so most analysis runs on the trailing twelve months. `financials(period: :quarterly)` returns quarters with their `quarter` number, and `ttm` adds the last four into one `Period` of type `:ttm`: flows summed, the diluted share count averaged, the latest balance sheet kept, and the four quarters before as its `prior`, so ratios on average balances, growth and the Piotroski score all work on it.
|
|
@@ -84,7 +90,7 @@ ttm.growth(:revenue) # against the previous twelve months
|
|
|
84
90
|
apple.valuation(price: 320, period: ttm).pe
|
|
85
91
|
```
|
|
86
92
|
|
|
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.
|
|
93
|
+
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
94
|
|
|
89
95
|
### Statements
|
|
90
96
|
|
|
@@ -94,9 +100,9 @@ EDGAR reports quarterly cash flows only year to date, and the fourth quarter onl
|
|
|
94
100
|
| `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
101
|
|
|
96
102
|
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 |
|
|
103
|
+
| `CashFlowStatement` | operating cash flow, capital expenditure, free cash flow, dividends paid, dividends per share, share repurchases | free cash flow, shareholder returns |
|
|
98
104
|
|
|
99
|
-
Outflows are positive amounts: `capital_expenditure` is what was spent.
|
|
105
|
+
Outflows are positive amounts whichever sign the filer gave them: `capital_expenditure` is what was spent.
|
|
100
106
|
|
|
101
107
|
## Banks
|
|
102
108
|
|
|
@@ -190,7 +196,7 @@ peers.rank(:pe, ascending: true)
|
|
|
190
196
|
peers.magic_formula
|
|
191
197
|
```
|
|
192
198
|
|
|
193
|
-
Tickers the provider cannot resolve are logged and left out, so one bad symbol does not spoil a screen.
|
|
199
|
+
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.
|
|
194
200
|
|
|
195
201
|
Ratios and multiples compare across currencies as they are; amounts do not. Pass `currency:` and the amounts, revenue, market cap, book value per share and the rest, are converted through exchange `Rates`, fetched from FMP or given by you on EDGAR, while everything that is a rate or a multiple stays untouched.
|
|
196
202
|
|
|
@@ -202,7 +208,7 @@ Fundamentalista.compare("AAPL", "NVO", currency: "USD", provider: :fmp).table(:r
|
|
|
202
208
|
|
|
203
209
|
## Provenance
|
|
204
210
|
|
|
205
|
-
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
|
|
211
|
+
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.
|
|
206
212
|
|
|
207
213
|
```ruby
|
|
208
214
|
year.source(:revenue)
|
|
@@ -211,6 +217,19 @@ year.source(:short_term_debt).derivation # => :summed
|
|
|
211
217
|
year.sources # every item
|
|
212
218
|
```
|
|
213
219
|
|
|
220
|
+
EDGAR keeps every value ever filed, so a period can also be read as it stood on a date, before later filings and restatements: `apple.financials(as_of: Date.new(2024, 6, 30))` returns the fiscal years filed by then, with the figures of that time. FMP has no history of its own statements and raises `UnsupportedPeriodError` there.
|
|
221
|
+
|
|
222
|
+
### Filings and earnings dates
|
|
223
|
+
|
|
224
|
+
`company.filings` lists the company's SEC filings from the submissions index, newest first, each with its form, filing date, the last day of the period it reports, the accession number and the URL of its primary document. `lag` is the days between the period's end and the filing, which is the company's reporting rhythm: Apple files 34 days after every quarter. `company.earnings` lists earnings releases from FMP, reported and scheduled, with the actual and estimated EPS and revenue, and `next_earnings` is the next one on the calendar.
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
apple.filings(form: "10-Q").first # => #<Fundamentalista::Filing form: "10-Q", filed_on: 2026-07-31, period_ended_on: 2026-06-27>
|
|
228
|
+
apple.filings(form: "10-K").map(&:lag) # => [34, 34, 34, ...]
|
|
229
|
+
apple.next_earnings # => #<Fundamentalista::EarningsRelease date: 2026-10-29, eps_estimate: 2.05> (FMP)
|
|
230
|
+
apple.earnings.last.surprise # EPS over the estimate, as a rate
|
|
231
|
+
```
|
|
232
|
+
|
|
214
233
|
## Valuation
|
|
215
234
|
|
|
216
235
|
`company.valuation` pairs the latest annual period with a market snapshot: the provider's quote, or a price you pass. EDGAR has no market data, so pass a price there.
|
|
@@ -324,11 +343,14 @@ valuation.residual_income_value(cost_of_equity: 0.09, growth: 0.04, years: 10)
|
|
|
324
343
|
| Prices and beta | No, bring a PriceHistory and a beta | Yes |
|
|
325
344
|
| Revenue segments | No | Yes |
|
|
326
345
|
| Exchange rates | No, bring Rates | Yes |
|
|
346
|
+
| Statements as of a past date | Yes | No |
|
|
347
|
+
| Filings | Yes | No |
|
|
348
|
+
| Earnings dates | No | Yes |
|
|
327
349
|
| Coverage | Companies filing with the SEC, US GAAP and IFRS, in their reporting currency | Global |
|
|
328
350
|
|
|
329
351
|
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.
|
|
330
352
|
|
|
331
|
-
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.
|
|
353
|
+
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 latest statements; a company that changed its reporting currency keeps the years in the new one. Quarterly data is a 10-Q affair, so IFRS filers get annual periods.
|
|
332
354
|
|
|
333
355
|
### Coverage
|
|
334
356
|
|
|
@@ -341,9 +363,13 @@ The concept map is measured, not guessed. `script/coverage` reads the latest ann
|
|
|
341
363
|
| diluted EPS, income before tax, interest expense, depreciation and amortization, diluted shares | 5% to 7% |
|
|
342
364
|
| shares outstanding, PP&E, capital expenditure | 11% to 14% |
|
|
343
365
|
|
|
344
|
-
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`.
|
|
366
|
+
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`.
|
|
367
|
+
|
|
368
|
+
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.
|
|
369
|
+
|
|
370
|
+
### Limitations
|
|
345
371
|
|
|
346
|
-
|
|
372
|
+
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.
|
|
347
373
|
|
|
348
374
|
### Caching
|
|
349
375
|
|
|
@@ -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.
|
|
@@ -13,9 +13,13 @@ module Fundamentalista
|
|
|
13
13
|
# company.estimate.eps # FMP only
|
|
14
14
|
# company.valuation_history(:pe) # {2025 => {price:, pe:}, ...}
|
|
15
15
|
# company.segments(by: :geography).share # FMP only
|
|
16
|
+
# company.financials(as_of: Date.new(2024, 6, 30)) # as they stood then, EDGAR only
|
|
17
|
+
# company.filings(form: "10-Q").first.filed_on # EDGAR only
|
|
18
|
+
# company.next_earnings.date # FMP only
|
|
16
19
|
#
|
|
17
20
|
class Company
|
|
18
21
|
include Inspectable
|
|
22
|
+
include Serializable
|
|
19
23
|
|
|
20
24
|
attr_reader :ticker, :name, :cik, :currency, :provider
|
|
21
25
|
|
|
@@ -34,9 +38,14 @@ module Fundamentalista
|
|
|
34
38
|
end
|
|
35
39
|
|
|
36
40
|
# Returns the Financials, newest first. +period+ is +:annual+ or
|
|
37
|
-
# +:quarterly+ and +limit+ the number of periods.
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
# +:quarterly+ and +limit+ the number of periods. Given +as_of+, the
|
|
42
|
+
# statements read as they stood on that date, before later filings and
|
|
43
|
+
# restatements, which EDGAR alone can do. Raises NoFinancialsError
|
|
44
|
+
# when the provider has none, as with a fresh listing.
|
|
45
|
+
def financials(period: :annual, limit: 5, as_of: nil)
|
|
46
|
+
@financials[[period, limit, as_of]] ||= provider.financials(self, period: period, limit: limit, as_of: as_of).tap do |found|
|
|
47
|
+
raise NoFinancialsError, "#{provider.class.slug} has no #{period} statements for #{ticker}" if found.none?
|
|
48
|
+
end
|
|
40
49
|
end
|
|
41
50
|
|
|
42
51
|
# Returns the PriceHistory between +from+ and +to+. Raises
|
|
@@ -73,6 +82,25 @@ module Fundamentalista
|
|
|
73
82
|
@segments[by] ||= provider.segments(self, by: by)
|
|
74
83
|
end
|
|
75
84
|
|
|
85
|
+
# Returns the company's Filings with the SEC, newest first, or those
|
|
86
|
+
# of one +form+ such as "10-Q". EDGAR only.
|
|
87
|
+
def filings(form: nil)
|
|
88
|
+
@filings ||= provider.filings(self)
|
|
89
|
+
form ? @filings.select { |filing| filing.form == form } : @filings
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Returns the EarningsReleases the provider knows, reported and
|
|
93
|
+
# scheduled, oldest first. Empty for EDGAR.
|
|
94
|
+
def earnings
|
|
95
|
+
@earnings ||= provider.earnings(self).sort_by(&:date)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Returns the next EarningsRelease on or after +after+, today by
|
|
99
|
+
# default, or +nil+.
|
|
100
|
+
def next_earnings(after: Date.today)
|
|
101
|
+
earnings.find { |release| release.date >= after }
|
|
102
|
+
end
|
|
103
|
+
|
|
76
104
|
# Returns the trailing twelve months as a Period, built from the last
|
|
77
105
|
# +quarters+ quarterly periods; eight give it a prior period too.
|
|
78
106
|
def ttm(quarters: 8)
|
|
@@ -100,7 +128,8 @@ module Fundamentalista
|
|
|
100
128
|
# Returns the Valuation of the latest annual period at +price+, or at
|
|
101
129
|
# the provider's quote when no price is given, with the next fiscal
|
|
102
130
|
# year's Estimate when the provider has one.
|
|
103
|
-
def valuation(price: nil, period:
|
|
131
|
+
def valuation(price: nil, period: nil)
|
|
132
|
+
period ||= latest_period
|
|
104
133
|
Valuation.new(period, price ? Quote.new(price: price) : quote, estimate: estimate(after: period.ended_on))
|
|
105
134
|
end
|
|
106
135
|
|
|
@@ -109,14 +138,17 @@ module Fundamentalista
|
|
|
109
138
|
latest_period.ratios
|
|
110
139
|
end
|
|
111
140
|
|
|
112
|
-
# The latest annual Period.
|
|
113
|
-
# has no annual statements for the company, as with a fresh listing.
|
|
141
|
+
# The latest annual Period.
|
|
114
142
|
def latest_period
|
|
115
|
-
financials.latest
|
|
143
|
+
financials.latest
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def to_h
|
|
147
|
+
{ ticker: ticker, name: name, cik: cik, currency: currency, beta: beta, provider: provider.class.slug }
|
|
116
148
|
end
|
|
117
149
|
|
|
118
150
|
def inspect_attributes # :nodoc:
|
|
119
|
-
|
|
151
|
+
to_h.slice(:ticker, :name, :cik, :provider)
|
|
120
152
|
end
|
|
121
153
|
end
|
|
122
154
|
end
|
|
@@ -3,14 +3,16 @@
|
|
|
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
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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.
|
|
9
10
|
#
|
|
10
11
|
# peers = Fundamentalista.compare("AAPL", "MSFT", prices: { "AAPL" => 320, "MSFT" => 500 })
|
|
11
12
|
# peers.table(:roe, :pe, :piotroski) # => {"AAPL" => {roe: ..., pe: ..., piotroski: 8}, ...}
|
|
12
13
|
# peers.rank(:pe, ascending: true) # => ["MSFT", "AAPL"]
|
|
13
14
|
# peers.magic_formula # => ["AAPL", "MSFT"]
|
|
15
|
+
# Fundamentalista.compare("AAPL", "MSFT", period: :ttm).values(:roe)
|
|
14
16
|
# Fundamentalista.compare("AAPL", "NVO", currency: "USD").values(:revenue)
|
|
15
17
|
#
|
|
16
18
|
class Comparison
|
|
@@ -23,13 +25,18 @@ module Fundamentalista
|
|
|
23
25
|
|
|
24
26
|
attr_reader :companies
|
|
25
27
|
|
|
28
|
+
# +:annual+ for the latest fiscal year, +:ttm+ for the trailing twelve
|
|
29
|
+
# months.
|
|
30
|
+
attr_reader :period
|
|
31
|
+
|
|
26
32
|
# The currency amounts are converted into, or +nil+ to leave each
|
|
27
33
|
# company in its own.
|
|
28
34
|
attr_reader :currency
|
|
29
35
|
|
|
30
|
-
def initialize(companies, prices: {}, currency: nil, rates: nil)
|
|
36
|
+
def initialize(companies, prices: {}, period: :annual, currency: nil, rates: nil)
|
|
31
37
|
@companies = companies.to_h { |company| [company.ticker, company] }
|
|
32
38
|
@prices = prices.transform_keys(&:to_s)
|
|
39
|
+
@period = period
|
|
33
40
|
@currency = currency
|
|
34
41
|
@rates = rates
|
|
35
42
|
end
|
|
@@ -83,7 +90,7 @@ module Fundamentalista
|
|
|
83
90
|
private
|
|
84
91
|
|
|
85
92
|
def metric(company, name)
|
|
86
|
-
period = company
|
|
93
|
+
period = period_of(company)
|
|
87
94
|
valuation = valuation_of(company, period)
|
|
88
95
|
return period.ratios.public_send(name) if Ratios::ALL.include?(name)
|
|
89
96
|
if Valuation::ALL.include?(name)
|
|
@@ -94,6 +101,10 @@ module Fundamentalista
|
|
|
94
101
|
converted(period.metric(name), period, amount: !name.end_with?('shares_outstanding', 'diluted_shares'))
|
|
95
102
|
end
|
|
96
103
|
|
|
104
|
+
def period_of(company)
|
|
105
|
+
period == :ttm ? company.ttm : company.latest_period
|
|
106
|
+
end
|
|
107
|
+
|
|
97
108
|
def converted(value, period, amount:)
|
|
98
109
|
return value if value.nil? || !amount || currency.nil? || period.currency == currency
|
|
99
110
|
|
|
@@ -36,7 +36,8 @@ module Fundamentalista
|
|
|
36
36
|
# The Financial Modeling Prep API key.
|
|
37
37
|
option :fmp_api_key
|
|
38
38
|
|
|
39
|
-
# The provider Fundamentalista.company uses when none is given
|
|
39
|
+
# The provider Fundamentalista.company uses when none is given: a
|
|
40
|
+
# slug, or a list of slugs tried in order.
|
|
40
41
|
option :default_provider, :edgar
|
|
41
42
|
|
|
42
43
|
# Seconds to wait for a provider response.
|
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.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fundamentalista
|
|
4
|
+
# One earnings release, past or scheduled: its date, the diluted EPS
|
|
5
|
+
# and revenue reported, and what analysts expected. A scheduled release
|
|
6
|
+
# has estimates and no actuals yet.
|
|
7
|
+
class EarningsRelease
|
|
8
|
+
include Inspectable
|
|
9
|
+
include Serializable
|
|
10
|
+
|
|
11
|
+
attr_reader :date, :eps, :eps_estimate, :revenue, :revenue_estimate
|
|
12
|
+
|
|
13
|
+
def initialize(date:, eps: nil, eps_estimate: nil, revenue: nil, revenue_estimate: nil)
|
|
14
|
+
@date = date
|
|
15
|
+
@eps = Decimal.wrap(eps)
|
|
16
|
+
@eps_estimate = Decimal.wrap(eps_estimate)
|
|
17
|
+
@revenue = Decimal.wrap(revenue)
|
|
18
|
+
@revenue_estimate = Decimal.wrap(revenue_estimate)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Whether the results are out.
|
|
22
|
+
def reported?
|
|
23
|
+
!eps.nil?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The EPS surprise as a rate over the estimate, or +nil+ before the
|
|
27
|
+
# release or without an estimate.
|
|
28
|
+
def surprise
|
|
29
|
+
eps && eps_estimate && Decimal.ratio(eps - eps_estimate, eps_estimate.abs)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_h
|
|
33
|
+
{ date: date, eps: eps, eps_estimate: eps_estimate, revenue: revenue, revenue_estimate: revenue_estimate }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def inspect_attributes # :nodoc:
|
|
37
|
+
{ date: date, eps: eps, eps_estimate: eps_estimate }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -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
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fundamentalista
|
|
4
|
+
# One filing with the SEC: its form, the day it was filed, the last day
|
|
5
|
+
# of the period it reports, its accession number and the URL of its
|
|
6
|
+
# primary document. The lag between a period's end and its filing is
|
|
7
|
+
# what a company's reporting rhythm looks like.
|
|
8
|
+
class Filing
|
|
9
|
+
include Inspectable
|
|
10
|
+
include Serializable
|
|
11
|
+
|
|
12
|
+
attr_reader :form, :filed_on, :period_ended_on, :accession, :url
|
|
13
|
+
|
|
14
|
+
def initialize(form:, filed_on:, accession:, url:, period_ended_on: nil)
|
|
15
|
+
@form = form
|
|
16
|
+
@filed_on = filed_on
|
|
17
|
+
@period_ended_on = period_ended_on
|
|
18
|
+
@accession = accession
|
|
19
|
+
@url = url
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The days between the end of the period reported and the filing, or
|
|
23
|
+
# +nil+ for filings that report no period.
|
|
24
|
+
def lag
|
|
25
|
+
period_ended_on && (filed_on - period_ended_on).to_i
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_h
|
|
29
|
+
{ form: form, filed_on: filed_on, period_ended_on: period_ended_on, accession: accession, url: url }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def inspect_attributes # :nodoc:
|
|
33
|
+
to_h.slice(:form, :filed_on, :period_ended_on)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
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
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fundamentalista
|
|
4
|
+
# One line item Period#metric resolves: its name, the statement it lives
|
|
5
|
+
# on, whether it is reported or derived, its unit, and whether it is a
|
|
6
|
+
# balance at a date or a flow over a period. The catalogue lets an
|
|
7
|
+
# application offer only what the gem can fill in.
|
|
8
|
+
#
|
|
9
|
+
# Fundamentalista.metrics.map(&:name) # => [:revenue, :cost_of_revenue, ...]
|
|
10
|
+
# Fundamentalista.metric(:net_debt).instant? # => true
|
|
11
|
+
#
|
|
12
|
+
class Metric
|
|
13
|
+
include Inspectable
|
|
14
|
+
include Serializable
|
|
15
|
+
|
|
16
|
+
STATEMENTS = { income: IncomeStatement, balance: BalanceSheet, cash_flow: CashFlowStatement,
|
|
17
|
+
banking: Banking, insurance: Insurance }.freeze
|
|
18
|
+
INSTANTS = (Banking::INSTANT + Insurance::INSTANT).freeze
|
|
19
|
+
private_constant :STATEMENTS, :INSTANTS
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# Every metric, reported ones first within each statement.
|
|
23
|
+
def all
|
|
24
|
+
@all ||= STATEMENTS.flat_map { |statement, klass| of(statement, klass) }
|
|
25
|
+
.push(new(name: :owner_earnings, statement: :period, derived: true))
|
|
26
|
+
.uniq(&:name)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The metric called +name+. Raises ArgumentError for a name no
|
|
30
|
+
# statement reports.
|
|
31
|
+
def find(name)
|
|
32
|
+
all.find { |metric| metric.name == name.to_sym } || raise(ArgumentError, "Unknown metric #{name.inspect}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def of(statement, klass)
|
|
38
|
+
derived = klass.public_instance_methods(false).reject { |method| method.end_with?('?') } - klass.fields
|
|
39
|
+
klass.fields.map { |name| new(name: name, statement: statement) } +
|
|
40
|
+
derived.map { |name| new(name: name, statement: statement, derived: true) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
attr_reader :name, :statement
|
|
45
|
+
|
|
46
|
+
def initialize(name:, statement:, derived: false)
|
|
47
|
+
@name = name
|
|
48
|
+
@statement = statement
|
|
49
|
+
@derived = derived
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Whether the item is computed from others rather than reported.
|
|
53
|
+
def derived?
|
|
54
|
+
@derived
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# +:currency+ for amounts, +:shares+ for share counts, +:per_share+ or
|
|
58
|
+
# +:rate+.
|
|
59
|
+
def unit
|
|
60
|
+
case name
|
|
61
|
+
when /per_share|\Aeps_/ then :per_share
|
|
62
|
+
when /shares/ then :shares
|
|
63
|
+
when /_rate\z|_ratio\z/ then :rate
|
|
64
|
+
else :currency
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Whether the item is a balance at the period's end rather than a
|
|
69
|
+
# flow over the period.
|
|
70
|
+
def instant?
|
|
71
|
+
statement == :balance || INSTANTS.include?(name)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def to_h
|
|
75
|
+
{ name: name, statement: statement, unit: unit, derived: derived?, instant: instant? }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
alias inspect_attributes to_h
|
|
79
|
+
end
|
|
80
|
+
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,9 @@
|
|
|
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
|
|
7
|
-
# get an HTTP client with retries and
|
|
6
|
+
# PriceHistory, its revenue Segments, its Filings and EarningsReleases,
|
|
7
|
+
# and exchange Rates. Subclasses get an HTTP client with retries and
|
|
8
|
+
# error mapping.
|
|
8
9
|
class Provider
|
|
9
10
|
class << self
|
|
10
11
|
# The symbol the provider is registered under.
|
|
@@ -23,7 +24,7 @@ module Fundamentalista
|
|
|
23
24
|
raise NotImplementedError
|
|
24
25
|
end
|
|
25
26
|
|
|
26
|
-
def financials(company, period:, limit:)
|
|
27
|
+
def financials(company, period:, limit:, as_of: nil)
|
|
27
28
|
raise NotImplementedError
|
|
28
29
|
end
|
|
29
30
|
|
|
@@ -47,6 +48,14 @@ module Fundamentalista
|
|
|
47
48
|
raise NotImplementedError
|
|
48
49
|
end
|
|
49
50
|
|
|
51
|
+
def filings(company)
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def earnings(company)
|
|
56
|
+
raise NotImplementedError
|
|
57
|
+
end
|
|
58
|
+
|
|
50
59
|
private
|
|
51
60
|
|
|
52
61
|
def get(url, params = {}, headers: {})
|