fundamentalista 0.6.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 +9 -0
- data/README.md +22 -1
- data/lib/fundamentalista/company.rb +29 -5
- data/lib/fundamentalista/configuration.rb +2 -1
- data/lib/fundamentalista/earnings_release.rb +40 -0
- data/lib/fundamentalista/filing.rb +36 -0
- data/lib/fundamentalista/metric.rb +80 -0
- data/lib/fundamentalista/provider.rb +12 -3
- data/lib/fundamentalista/providers/chain.rb +48 -0
- data/lib/fundamentalista/providers/edgar/facts.rb +21 -6
- data/lib/fundamentalista/providers/edgar.rb +25 -5
- data/lib/fundamentalista/providers/fmp.rb +13 -3
- data/lib/fundamentalista/version.rb +1 -1
- data/lib/fundamentalista.rb +17 -3
- 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,14 @@
|
|
|
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
|
+
|
|
3
12
|
## 0.6.0
|
|
4
13
|
|
|
5
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`.
|
data/README.md
CHANGED
|
@@ -52,8 +52,11 @@ apple = Fundamentalista.company("AAPL", provider: :fmp)
|
|
|
52
52
|
apple.name # => "Apple Inc."
|
|
53
53
|
apple.cik # => 320193
|
|
54
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
|
|
55
56
|
```
|
|
56
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
|
+
|
|
57
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.
|
|
58
61
|
|
|
59
62
|
```ruby
|
|
@@ -73,6 +76,8 @@ financials.series(:dividends_paid_per_share) # any of them per share
|
|
|
73
76
|
financials.growth(:free_cash_flow_per_share, years: 5)
|
|
74
77
|
```
|
|
75
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
|
+
|
|
76
81
|
### Quarters and the trailing twelve months
|
|
77
82
|
|
|
78
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.
|
|
@@ -212,6 +217,19 @@ year.source(:short_term_debt).derivation # => :summed
|
|
|
212
217
|
year.sources # every item
|
|
213
218
|
```
|
|
214
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
|
+
|
|
215
233
|
## Valuation
|
|
216
234
|
|
|
217
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.
|
|
@@ -325,11 +343,14 @@ valuation.residual_income_value(cost_of_equity: 0.09, growth: 0.04, years: 10)
|
|
|
325
343
|
| Prices and beta | No, bring a PriceHistory and a beta | Yes |
|
|
326
344
|
| Revenue segments | No | Yes |
|
|
327
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 |
|
|
328
349
|
| Coverage | Companies filing with the SEC, US GAAP and IFRS, in their reporting currency | Global |
|
|
329
350
|
|
|
330
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.
|
|
331
352
|
|
|
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
|
|
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.
|
|
333
354
|
|
|
334
355
|
### Coverage
|
|
335
356
|
|
|
@@ -13,6 +13,9 @@ 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
|
|
@@ -35,11 +38,13 @@ module Fundamentalista
|
|
|
35
38
|
end
|
|
36
39
|
|
|
37
40
|
# Returns the Financials, newest first. +period+ is +:annual+ or
|
|
38
|
-
# +:quarterly+ and +limit+ the number of periods.
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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?
|
|
43
48
|
end
|
|
44
49
|
end
|
|
45
50
|
|
|
@@ -77,6 +82,25 @@ module Fundamentalista
|
|
|
77
82
|
@segments[by] ||= provider.segments(self, by: by)
|
|
78
83
|
end
|
|
79
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
|
+
|
|
80
104
|
# Returns the trailing twelve months as a Period, built from the last
|
|
81
105
|
# +quarters+ quarterly periods; eight give it a prior period too.
|
|
82
106
|
def ttm(quarters: 8)
|
|
@@ -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.
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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: {})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fundamentalista
|
|
4
|
+
module Providers
|
|
5
|
+
# Several providers tried in order: a company is resolved by the first
|
|
6
|
+
# one that lists it, and every question goes to the first one with an
|
|
7
|
+
# answer. Reading EDGAR for what it files and FMP for the rest, or for
|
|
8
|
+
# the quarters of a 20-F filer, is one list away.
|
|
9
|
+
#
|
|
10
|
+
# Fundamentalista.company("YPF", provider: [:edgar, :fmp]).financials(period: :quarterly)
|
|
11
|
+
#
|
|
12
|
+
class Chain < Provider
|
|
13
|
+
attr_reader :providers
|
|
14
|
+
|
|
15
|
+
def initialize(config, providers)
|
|
16
|
+
super(config)
|
|
17
|
+
@providers = providers
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def company(ticker)
|
|
21
|
+
found = attempt(:company, ticker)
|
|
22
|
+
Company.new(**found.to_h.except(:provider), provider: self)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
%i[financials quote estimates prices segments rates filings earnings].each do |question|
|
|
26
|
+
define_method(question) { |*args, **options| attempt(question, *args, **options) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def attempt(question, *args, **options)
|
|
32
|
+
outcome = nil
|
|
33
|
+
providers.each do |provider|
|
|
34
|
+
outcome = provider.public_send(question, *args, **options)
|
|
35
|
+
return outcome unless empty?(outcome)
|
|
36
|
+
rescue Error, NotImplementedError => e
|
|
37
|
+
config.logger.debug("#{provider.class.slug} could not answer #{question}: #{e.message}")
|
|
38
|
+
outcome = e
|
|
39
|
+
end
|
|
40
|
+
outcome.is_a?(Exception) ? raise(outcome) : outcome
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def empty?(outcome)
|
|
44
|
+
outcome.nil? || (outcome.respond_to?(:none?) && outcome.none?)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -18,6 +18,9 @@ module Fundamentalista
|
|
|
18
18
|
# Weighted average share counts do not subtract, so the year to date
|
|
19
19
|
# average stands in for the quarter, and per share earnings are left
|
|
20
20
|
# for the statement to compute from it.
|
|
21
|
+
#
|
|
22
|
+
# Given +as_of+, only values filed by that date count, so the
|
|
23
|
+
# statements read as they stood then.
|
|
21
24
|
class Facts # :nodoc:
|
|
22
25
|
ANNUAL = (350..380)
|
|
23
26
|
QUARTER = (80..100)
|
|
@@ -27,19 +30,20 @@ module Fundamentalista
|
|
|
27
30
|
TAXONOMIES = %w[us-gaap ifrs-full dei].freeze
|
|
28
31
|
CURRENCY = /\A[A-Z]{3}\b/
|
|
29
32
|
|
|
30
|
-
def initialize(document)
|
|
33
|
+
def initialize(document, as_of: nil)
|
|
31
34
|
facts = document.fetch('facts', {})
|
|
32
35
|
@facts = TAXONOMIES.map { |taxonomy| facts.fetch(taxonomy, {}) }
|
|
36
|
+
@as_of = as_of&.iso8601
|
|
33
37
|
end
|
|
34
38
|
|
|
35
39
|
# The ISO code of the currency revenue, or failing that the balance
|
|
36
|
-
# sheet,
|
|
40
|
+
# sheet, was last reported in, so a company that changed its
|
|
41
|
+
# reporting currency is read in the new one; +nil+ when the
|
|
42
|
+
# document says nothing.
|
|
37
43
|
def currency
|
|
38
44
|
return @currency if defined?(@currency)
|
|
39
45
|
|
|
40
|
-
@currency = (Tags::INCOME[:revenue] + ['Assets']).lazy.filter_map
|
|
41
|
-
units(concept).keys.find { |unit| unit.match?(/\A[A-Z]{3}\z/) }
|
|
42
|
-
end.first
|
|
46
|
+
@currency = (Tags::INCOME[:revenue] + ['Assets']).lazy.filter_map { |concept| latest_currency(concept) }.first
|
|
43
47
|
end
|
|
44
48
|
|
|
45
49
|
# The fiscal years on file, newest first, as [year, ended_on] pairs.
|
|
@@ -176,8 +180,19 @@ module Fundamentalista
|
|
|
176
180
|
(Date.parse(entry['end']) - Date.parse(entry['start'])).to_i
|
|
177
181
|
end
|
|
178
182
|
|
|
183
|
+
def latest_currency(concept)
|
|
184
|
+
units(concept).filter_map do |unit, entries|
|
|
185
|
+
last = filed(entries).max_by { |entry| entry['filed'] }
|
|
186
|
+
[unit, last['filed']] if last && unit.match?(/\A[A-Z]{3}\z/)
|
|
187
|
+
end.max_by(&:last)&.first
|
|
188
|
+
end
|
|
189
|
+
|
|
179
190
|
def entries(concept)
|
|
180
|
-
units(concept).select { |unit, _| !unit.match?(CURRENCY) || unit.start_with?(currency.to_s) }.values.flatten
|
|
191
|
+
filed(units(concept).select { |unit, _| !unit.match?(CURRENCY) || unit.start_with?(currency.to_s) }.values.flatten)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def filed(entries)
|
|
195
|
+
entries.select { |entry| @as_of.nil? || entry['filed'] <= @as_of }
|
|
181
196
|
end
|
|
182
197
|
|
|
183
198
|
def units(concept)
|
|
@@ -8,12 +8,15 @@ module Fundamentalista
|
|
|
8
8
|
# filings and quarterly ones from 10-Q filings, with fourth quarters
|
|
9
9
|
# derived from the annual figures; foreign filers reporting under IFRS
|
|
10
10
|
# in 20-F and 40-F filings get their annual periods in their own
|
|
11
|
-
# currency
|
|
12
|
-
#
|
|
13
|
-
#
|
|
11
|
+
# currency, and any period can be read as it stood on a date. It has
|
|
12
|
+
# no market data, so quotes come from the price you pass to
|
|
13
|
+
# Company#valuation. Requests are spaced to respect the SEC's rate
|
|
14
|
+
# limit, see Configuration#edgar_requests_per_second.
|
|
14
15
|
class Edgar < Provider
|
|
15
16
|
TICKERS_URL = 'https://www.sec.gov/files/company_tickers.json'
|
|
16
17
|
FACTS_URL = 'https://data.sec.gov/api/xbrl/companyfacts/CIK%010d.json'
|
|
18
|
+
SUBMISSIONS_URL = 'https://data.sec.gov/submissions/CIK%010d.json'
|
|
19
|
+
DOCUMENT_URL = 'https://www.sec.gov/Archives/edgar/data/%d/%s/%s'
|
|
17
20
|
|
|
18
21
|
# Resolves a ticker, or a CIK given as digits for registrants the
|
|
19
22
|
# ticker list no longer carries, such as the predecessor of a company
|
|
@@ -26,8 +29,8 @@ module Fundamentalista
|
|
|
26
29
|
Company.new(ticker: entry['ticker'], name: entry['title'], cik: entry['cik_str'], provider: self)
|
|
27
30
|
end
|
|
28
31
|
|
|
29
|
-
def financials(company, period: :annual, limit: 5)
|
|
30
|
-
facts = Facts.new(company_facts(company))
|
|
32
|
+
def financials(company, period: :annual, limit: 5, as_of: nil)
|
|
33
|
+
facts = Facts.new(company_facts(company), as_of: as_of)
|
|
31
34
|
@currency = facts.currency
|
|
32
35
|
periods = case period
|
|
33
36
|
when :annual then facts.fiscal_years.first(limit).map { |year, ended_on| annual_period(facts, year, ended_on) }
|
|
@@ -45,6 +48,23 @@ module Fundamentalista
|
|
|
45
48
|
[]
|
|
46
49
|
end
|
|
47
50
|
|
|
51
|
+
def earnings(_company)
|
|
52
|
+
[]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The filings in the submissions index, newest first: the last
|
|
56
|
+
# thousand, which reach back a decade or more of 10-K and 10-Q.
|
|
57
|
+
def filings(company)
|
|
58
|
+
recent = get(format(SUBMISSIONS_URL, company.cik), headers: headers).dig('filings', 'recent')
|
|
59
|
+
recent['form'].each_index.map do |index|
|
|
60
|
+
accession = recent['accessionNumber'][index]
|
|
61
|
+
reported = recent['reportDate'][index]
|
|
62
|
+
Filing.new(form: recent['form'][index], filed_on: Date.parse(recent['filingDate'][index]),
|
|
63
|
+
period_ended_on: reported.to_s.empty? ? nil : Date.parse(reported), accession: accession,
|
|
64
|
+
url: format(DOCUMENT_URL, company.cik, accession.delete('-'), recent['primaryDocument'][index]))
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
48
68
|
def prices(company, **)
|
|
49
69
|
raise QuoteUnavailableError, "EDGAR has no prices for #{company.ticker}; pass a PriceHistory"
|
|
50
70
|
end
|
|
@@ -4,8 +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, revenue
|
|
8
|
-
# exchange rates.
|
|
7
|
+
# quotes, analyst estimates, earnings releases, daily prices, revenue
|
|
8
|
+
# segments and exchange rates.
|
|
9
9
|
class FMP < Provider
|
|
10
10
|
BASE_URL = 'https://financialmodelingprep.com/stable/'
|
|
11
11
|
|
|
@@ -17,7 +17,9 @@ module Fundamentalista
|
|
|
17
17
|
currency: profile['currency'], beta: profile['beta'], provider: self)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def financials(company, period: :annual, limit: 5)
|
|
20
|
+
def financials(company, period: :annual, limit: 5, as_of: nil)
|
|
21
|
+
raise UnsupportedPeriodError, 'FMP serves statements as they stand today; as_of: needs EDGAR' if as_of
|
|
22
|
+
|
|
21
23
|
params = { symbol: company.ticker, period: period == :quarterly ? 'quarter' : 'annual', limit: limit }
|
|
22
24
|
statements = %w[income-statement balance-sheet-statement cash-flow-statement].map do |endpoint|
|
|
23
25
|
by_date(fetch(endpoint, params))
|
|
@@ -64,6 +66,14 @@ module Fundamentalista
|
|
|
64
66
|
end
|
|
65
67
|
end
|
|
66
68
|
|
|
69
|
+
# The last dozen earnings releases, scheduled ones included.
|
|
70
|
+
def earnings(company)
|
|
71
|
+
fetch('earnings', symbol: company.ticker, limit: 12).map do |row|
|
|
72
|
+
EarningsRelease.new(date: Date.parse(row['date']), eps: row['epsActual'], eps_estimate: row['epsEstimated'],
|
|
73
|
+
revenue: row['revenueActual'], revenue_estimate: row['revenueEstimated'])
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
67
77
|
private
|
|
68
78
|
|
|
69
79
|
def build_period(date, income, balance, cash_flow)
|
data/lib/fundamentalista.rb
CHANGED
|
@@ -29,8 +29,9 @@ loader.setup
|
|
|
29
29
|
#
|
|
30
30
|
module Fundamentalista
|
|
31
31
|
class << self
|
|
32
|
-
# Returns the Company behind +ticker+, resolved through +provider
|
|
33
|
-
#
|
|
32
|
+
# Returns the Company behind +ticker+, resolved through +provider+:
|
|
33
|
+
# +:edgar+, +:fmp+, or a list of them tried in order, defaulting to
|
|
34
|
+
# the configured provider.
|
|
34
35
|
def company(ticker, provider: config.default_provider)
|
|
35
36
|
provider(provider).company(ticker)
|
|
36
37
|
end
|
|
@@ -51,14 +52,27 @@ module Fundamentalista
|
|
|
51
52
|
Comparison.new(companies, prices: prices, period: period, currency: currency, rates: rates)
|
|
52
53
|
end
|
|
53
54
|
|
|
54
|
-
# Returns an instance of the provider registered under +slug
|
|
55
|
+
# Returns an instance of the provider registered under +slug+, or a
|
|
56
|
+
# Providers::Chain of them when given a list.
|
|
55
57
|
def provider(slug)
|
|
58
|
+
return Providers::Chain.new(config, slug.map { |each| provider(each) }) if slug.is_a?(Array)
|
|
59
|
+
|
|
56
60
|
klass = providers.fetch(slug.to_sym) do
|
|
57
61
|
raise ConfigurationError, "Unknown provider #{slug.inspect}. Known providers: #{providers.keys.join(', ')}"
|
|
58
62
|
end
|
|
59
63
|
klass.new(config)
|
|
60
64
|
end
|
|
61
65
|
|
|
66
|
+
# Every line item a Period resolves, as Metric objects.
|
|
67
|
+
def metrics
|
|
68
|
+
Metric.all
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The Metric called +name+.
|
|
72
|
+
def metric(name)
|
|
73
|
+
Metric.find(name)
|
|
74
|
+
end
|
|
75
|
+
|
|
62
76
|
# The registered providers, keyed by slug.
|
|
63
77
|
def providers
|
|
64
78
|
@providers ||= { edgar: Providers::Edgar, fmp: Providers::FMP }
|
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.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bruno Costanzo
|
|
@@ -87,15 +87,19 @@ files:
|
|
|
87
87
|
- lib/fundamentalista/configuration.rb
|
|
88
88
|
- lib/fundamentalista/dcf.rb
|
|
89
89
|
- lib/fundamentalista/decimal.rb
|
|
90
|
+
- lib/fundamentalista/earnings_release.rb
|
|
90
91
|
- lib/fundamentalista/error.rb
|
|
91
92
|
- lib/fundamentalista/estimate.rb
|
|
93
|
+
- lib/fundamentalista/filing.rb
|
|
92
94
|
- lib/fundamentalista/financials.rb
|
|
93
95
|
- lib/fundamentalista/income_statement.rb
|
|
94
96
|
- lib/fundamentalista/inspectable.rb
|
|
95
97
|
- lib/fundamentalista/insurance.rb
|
|
98
|
+
- lib/fundamentalista/metric.rb
|
|
96
99
|
- lib/fundamentalista/period.rb
|
|
97
100
|
- lib/fundamentalista/price_history.rb
|
|
98
101
|
- lib/fundamentalista/provider.rb
|
|
102
|
+
- lib/fundamentalista/providers/chain.rb
|
|
99
103
|
- lib/fundamentalista/providers/edgar.rb
|
|
100
104
|
- lib/fundamentalista/providers/edgar/facts.rb
|
|
101
105
|
- lib/fundamentalista/providers/edgar/tags.rb
|