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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 60ef7c2913952daa4c9a4bcf633813d421326b9cc9ccc55755731ef536c51785
4
- data.tar.gz: 712549d9e3a33d73e2ce21ca19c5780267cdb64a02261b546dff0e62ac05c3e2
3
+ metadata.gz: 157c2e2b5398a2feb0e9c1d80b76b8273909a6d0e7fbd038929d647bb3b3d5e2
4
+ data.tar.gz: e4a34806e3588dcc4afdb41bf287523fc75500e1d74c13fb4aed7c477690bf6e
5
5
  SHA512:
6
- metadata.gz: a5d017ed989ad8ba888720041675e15bd683fdf391dcfb9dbdf5d14918f9acc88581854eeef5ed4995f69b3ad25074f5e60f1df63e420e03481ed387b342a302
7
- data.tar.gz: f839a5b66257300a5b139fac8d303b031ebfd282878b16bdaa6a79dd643d59a905b89499d1ab93195f623190b74d5c66cd5cf4fe36792ceddde8c4c4e0af364b
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 derived from year to date values.
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
- 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.
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 reports net interest income, the mark of a bank.
22
+ # Whether the company earns net interest income, the mark of a bank;
23
+ # an insurer or industrial tagging its net interest expense there is
24
+ # not one.
23
25
  def bank?
24
- !net_interest_income.nil? && !net_interest_income.zero?
26
+ !net_interest_income.nil? && net_interest_income.positive?
25
27
  end
26
28
 
27
29
  # Net interest income plus noninterest income, a bank's revenue.
@@ -2,9 +2,11 @@
2
2
 
3
3
  module Fundamentalista
4
4
  # One period of the cash flow statement. Outflows are positive amounts:
5
- # +capital_expenditure+ is what was spent, +dividends_paid+ what was paid.
5
+ # +capital_expenditure+ is what was spent, +dividends_paid+ what was paid,
6
+ # and +dividends_per_share+ what was declared on each share.
6
7
  class CashFlowStatement < Statement
7
- field :operating_cash_flow, :capital_expenditure, :free_cash_flow, :dividends_paid, :share_repurchases
8
+ field :operating_cash_flow, :capital_expenditure, :free_cash_flow, :dividends_paid, :dividends_per_share,
9
+ :share_repurchases
8
10
 
9
11
  # Free cash flow, derived from operating cash flow and capital
10
12
  # expenditure when not reported.
@@ -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
- def financials(period: :annual, limit: 5)
39
- @financials[[period, limit]] ||= provider.financials(self, period: period, limit: limit)
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: latest_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. Raises NoFinancialsError when the provider
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 || raise(NoFinancialsError, "#{provider.class.slug} has no annual statements for #{ticker}")
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
- { ticker: ticker, name: name, cik: cik, provider: provider.class.slug }
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. With a +currency+, amounts such as revenue or market
7
- # cap are converted into it through Rates, while ratios and multiples
8
- # stay as they are.
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.latest_period
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.
@@ -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 for errors raised while talking to a data provider. Carries
5
- # the HTTP response when there is one.
4
+ # Base class of every error the gem raises, so one rescue catches them
5
+ # all. Errors raised while talking to a data provider carry the HTTP
6
+ # response when there is one.
6
7
  class Error < StandardError
7
8
  attr_reader :response
8
9
 
@@ -12,12 +13,12 @@ module Fundamentalista
12
13
  end
13
14
  end
14
15
 
15
- class ConfigurationError < StandardError; end
16
+ class ConfigurationError < Error; end
16
17
  class CompanyNotFoundError < Error; end
17
18
  class UnauthorizedError < Error; end
18
19
  class RateLimitError < Error; end
19
20
  class ServerError < Error; end
20
- class UnsupportedPeriodError < StandardError; end
21
- class NoFinancialsError < StandardError; end
22
- class QuoteUnavailableError < StandardError; end
21
+ class UnsupportedPeriodError < Error; end
22
+ class NoFinancialsError < Error; end
23
+ class QuoteUnavailableError < Error; end
23
24
  end
@@ -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 statements as a Hash.
99
+ # Returns the period, its statements and its sources as a Hash.
100
100
  def to_h
101
101
  { fiscal_year: fiscal_year, quarter: quarter, type: type, ended_on: ended_on, currency: currency,
102
102
  income: income.to_h, balance: balance.to_h, cash_flow: cash_flow.to_h, banking: banking.to_h,
103
- insurance: insurance.to_h }
103
+ insurance: insurance.to_h, sources: sources.transform_values(&:to_h) }
104
104
  end
105
105
 
106
106
  # The days the period spans, for turning turnover into days.
@@ -137,19 +137,20 @@ module Fundamentalista
137
137
  # Returns the line item +name+ from whichever statement reports it, or
138
138
  # +nil+. Derived items such as +free_cash_flow+ count, so does
139
139
  # +owner_earnings+, and any of them followed by +_per_share+ is divided
140
- # by the shares outstanding.
140
+ # by the shares outstanding, unless a statement reports it per share
141
+ # already, as with +dividends_per_share+.
141
142
  #
142
143
  # period.metric(:revenue) # => 0.416161e12
143
144
  # period.metric(:free_cash_flow_per_share) # => 0.66854e1
144
145
  #
145
146
  def metric(name)
146
147
  return owner_earnings if name == :owner_earnings
147
- return per_share(name.to_s.delete_suffix('_per_share').to_sym) if name.end_with?('_per_share')
148
148
 
149
149
  statement = [income, balance, cash_flow, banking, insurance].find { |candidate| candidate.respond_to?(name) }
150
- raise ArgumentError, "Unknown metric #{name.inspect}" unless statement
150
+ return statement.public_send(name) if statement
151
+ return per_share(name.to_s.delete_suffix('_per_share').to_sym) if name.end_with?('_per_share')
151
152
 
152
- statement.public_send(name)
153
+ raise ArgumentError, "Unknown metric #{name.inspect}"
153
154
  end
154
155
 
155
156
  # Returns +name+ divided by the shares outstanding at the end of the
@@ -3,8 +3,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: {})