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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: eeff95349adcf9312850781f5792f603793c0a104cd49a370d7895eddb9bf334
4
- data.tar.gz: 1268d8bd7117f7a874de9d8fd65b51f13d70d2b5b1aae5b106e0426b20b1f852
3
+ metadata.gz: 157c2e2b5398a2feb0e9c1d80b76b8273909a6d0e7fbd038929d647bb3b3d5e2
4
+ data.tar.gz: e4a34806e3588dcc4afdb41bf287523fc75500e1d74c13fb4aed7c477690bf6e
5
5
  SHA512:
6
- metadata.gz: db32e147665acf8f33c7a0d8afe0df06377bd4273b70a33064f077919bf8a7684e06885a17c25b54921578dcf8e66863b921cb481c947e987b2e4181edfa2f2f
7
- data.tar.gz: aa1d5c94637e79a7f215b3bf7d0532ca1f6636a8ca5df2c233621b3237af58ac39fcf0664600d5baf5634a6c191378f79a9cf5a87434b3bddfb30e05155a529e
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 own statements. 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.
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. Raises
39
- # NoFinancialsError when the provider has none, as with a fresh listing.
40
- def financials(period: :annual, limit: 5)
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?
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 and its revenue Segments, and exchange Rates. Subclasses
7
- # get an HTTP client with retries and error mapping.
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, is reported in; +nil+ when the document says nothing.
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 do |concept|
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. It has no market data, so quotes come from the price you
12
- # pass to Company#valuation. Requests are spaced to respect the SEC's
13
- # rate limit, see Configuration#edgar_requests_per_second.
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 segments and
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)
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Fundamentalista
4
4
  # The version of the fundamentalista gem, as a string.
5
- VERSION = '0.6.0'
5
+ VERSION = '0.7.0'
6
6
  end
@@ -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
- # (+:edgar+ or +:fmp+, defaulting to the configured provider).
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.6.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