fundamentalista 0.1.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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +15 -0
  3. data/LICENSE +21 -0
  4. data/README.md +297 -0
  5. data/lib/fundamentalista/balance_sheet.rb +44 -0
  6. data/lib/fundamentalista/banking.rb +32 -0
  7. data/lib/fundamentalista/cash_flow_statement.rb +20 -0
  8. data/lib/fundamentalista/company.rb +81 -0
  9. data/lib/fundamentalista/comparison.rb +85 -0
  10. data/lib/fundamentalista/configuration.rb +71 -0
  11. data/lib/fundamentalista/dcf.rb +73 -0
  12. data/lib/fundamentalista/decimal.rb +57 -0
  13. data/lib/fundamentalista/error.rb +23 -0
  14. data/lib/fundamentalista/estimate.rb +38 -0
  15. data/lib/fundamentalista/financials.rb +103 -0
  16. data/lib/fundamentalista/income_statement.rb +40 -0
  17. data/lib/fundamentalista/inspectable.rb +26 -0
  18. data/lib/fundamentalista/period.rb +163 -0
  19. data/lib/fundamentalista/provider.rb +70 -0
  20. data/lib/fundamentalista/providers/edgar/facts.rb +172 -0
  21. data/lib/fundamentalista/providers/edgar/tags.rb +116 -0
  22. data/lib/fundamentalista/providers/edgar.rb +109 -0
  23. data/lib/fundamentalista/providers/fmp.rb +109 -0
  24. data/lib/fundamentalista/quote.rb +32 -0
  25. data/lib/fundamentalista/ratios.rb +231 -0
  26. data/lib/fundamentalista/scores/altman_z.rb +69 -0
  27. data/lib/fundamentalista/scores/beneish.rb +112 -0
  28. data/lib/fundamentalista/scores/piotroski.rb +111 -0
  29. data/lib/fundamentalista/serializable.rb +34 -0
  30. data/lib/fundamentalista/source.rb +27 -0
  31. data/lib/fundamentalista/statement.rb +49 -0
  32. data/lib/fundamentalista/valuation.rb +201 -0
  33. data/lib/fundamentalista/version.rb +6 -0
  34. data/lib/fundamentalista.rb +85 -0
  35. metadata +136 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 432e0a27a0bed71a2f55d3574bf27c0ac4a781d08217de34f2a9c9dfc3a4ea87
4
+ data.tar.gz: d8c0b9a22759ec8142e016c072b2d59a153b014f3811284c00ca0e86b34771a1
5
+ SHA512:
6
+ metadata.gz: 8be12b7c9de524de12ec34b09d9216408d4c03211a2c63ff145132619ae5652ab74ce85009cf25f2ac9cbd91571328f6d7848250ae1f7265f237165a87276c2d
7
+ data.tar.gz: bb4e2193d2f9ba6f76491f0ca66fc6bc5d11a253fddc1ccc1f8a61e46dee5956a8292a032c16a489b434086ea2a934d7f6c9e7fd313fb811c49812e0cc6c1412
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Financial statements as value objects with BigDecimal figures and derived line items.
6
+ - Ratios: profitability, liquidity, leverage, efficiency, the working capital cycle in days, cash flow, Sloan's accrual ratio, Greenblatt's return on capital and the DuPont decomposition.
7
+ - Piotroski F-score, Altman Z-score, Beneish M-score and owner earnings.
8
+ - Valuation: multiples including EV/EBIT, yields including Greenblatt's, Graham number, a discounted cash flow intrinsic value and the reverse DCF.
9
+ - Banking lines and ratios for banks, line item provenance from EDGAR, and peer comparison with rankings and the magic formula.
10
+ - `to_json` on every object, year-over-year growth for quarters, `Financials#year`, DCF with fading growth, share classes written with a dot, and a configuration that redacts keys.
11
+ - Any metric per share, for dividend, book value and cash flow histories.
12
+ - Quarterly periods from EDGAR, fourth quarters derived from the annual figures, and the trailing twelve months as a period.
13
+ - Analyst estimates from FMP, forward P/E and a PEG on expected growth.
14
+ - The EDGAR concept map validated over the 500 largest filers with `script/coverage`, cover page share counts, and a request throttle for the SEC's rate limit.
15
+ - Providers: SEC EDGAR (US GAAP and IFRS filers, free) and Financial Modeling Prep (annual and quarterly, quotes), behind a configurable cache.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bruno Costanzo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,297 @@
1
+ # Fundamentalista
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/fundamentalista.svg)](https://rubygems.org/gems/fundamentalista) [![CI](https://github.com/bruno-costanzo/fundamentalista/actions/workflows/ci.yml/badge.svg)](https://github.com/bruno-costanzo/fundamentalista/actions/workflows/ci.yml)
4
+
5
+ Fundamental analysis of listed companies, the Ruby way.
6
+
7
+ Fundamentalista turns a ticker into financial statements, ratios, scores and a valuation, with one API over every data source. It reads the SEC's EDGAR XBRL API, which is free and needs no key and covers US GAAP and IFRS filers alike, or Financial Modeling Prep when you want live quotes and analyst estimates.
8
+
9
+ ```ruby
10
+ company = Fundamentalista.company("AAPL")
11
+
12
+ year = company.financials.latest
13
+ year.income.revenue # => 0.416161e12
14
+ year.ratios.roe # => 0.1714e1
15
+ year.piotroski.score # => 8
16
+ company.financials.growth(:free_cash_flow, years: 3)
17
+
18
+ valuation = company.valuation(price: 320)
19
+ valuation.pe # => 0.4289e2
20
+ valuation.fcf_yield # => 0.0209e0
21
+ valuation.altman_z.zone # => :safe
22
+ valuation.intrinsic_value(growth: 0.06, discount_rate: 0.09)
23
+ ```
24
+
25
+ Every figure is a `BigDecimal`. Every derived number is `nil` when a filing lacks an input, so a ratio never raises on an incomplete company. Rates are decimals: `0.25` means 25%. Every object answers `to_h` with its figures intact and `to_json` with Floats and ISO dates, so `render json: company.financials` works in Rails.
26
+
27
+ ## Installation
28
+
29
+ ```ruby
30
+ gem "fundamentalista"
31
+ ```
32
+
33
+ ## Configuration
34
+
35
+ The SEC asks for a User-Agent naming your application and a contact email on every request. Financial Modeling Prep needs an API key.
36
+
37
+ ```ruby
38
+ Fundamentalista.configure do |config|
39
+ config.edgar_user_agent = "Acme Research research@acme.com"
40
+ config.fmp_api_key = ENV["FMP_API_KEY"]
41
+ config.default_provider = :edgar # or :fmp
42
+ end
43
+ ```
44
+
45
+ ## Companies and financials
46
+
47
+ `Fundamentalista.company` resolves a ticker through the default provider, or the one you name.
48
+
49
+ ```ruby
50
+ apple = Fundamentalista.company("AAPL")
51
+ apple = Fundamentalista.company("AAPL", provider: :fmp)
52
+ apple.name # => "Apple Inc."
53
+ apple.cik # => 320193
54
+ ```
55
+
56
+ `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
+
58
+ ```ruby
59
+ financials = apple.financials(limit: 5) # annual
60
+ financials = apple.financials(period: :quarterly) # quarters, newest first
61
+ ttm = apple.ttm # the trailing twelve months as one Period
62
+
63
+ financials.latest.income.net_income
64
+ financials.series(:revenue) # {2025 => ..., 2024 => ...}
65
+ financials.growth(:revenue) # CAGR over the whole history
66
+ financials.growth(:revenue, years: 3)
67
+ financials.latest.growth(:revenue) # against the prior period
68
+ financials.latest.yoy(:revenue) # against the same period a year earlier
69
+ financials.year(2024) # one period by fiscal year, or year(2026, 3) for a quarter
70
+ financials.latest.metric(:free_cash_flow) # any line item, from whichever statement has it
71
+ financials.series(:dividends_paid_per_share) # any of them per share
72
+ financials.growth(:free_cash_flow_per_share, years: 5)
73
+ ```
74
+
75
+ ### Quarters and the trailing twelve months
76
+
77
+ 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.
78
+
79
+ ```ruby
80
+ ttm = apple.ttm # eight quarters: four for the period, four for its prior
81
+ ttm.income.net_income
82
+ ttm.ratios.roe
83
+ ttm.growth(:revenue) # against the previous twelve months
84
+ apple.valuation(price: 320, period: ttm).pe
85
+ ```
86
+
87
+ EDGAR reports quarterly cash flows only year to date, and the fourth quarter only as the annual figure. Fundamentalista reads a quarter directly when the filing has a three month value and derives it otherwise as the difference between two year to date values that share a fiscal year start.
88
+
89
+ ### Statements
90
+
91
+ | Statement | Reported items | Derived when missing |
92
+ | --- | --- | --- |
93
+ | `IncomeStatement` | revenue, cost of revenue, gross profit, SG&A, operating income, interest expense, income before tax, income tax, net income, depreciation and amortization, EBITDA, diluted EPS, diluted shares | gross profit, EBITDA, diluted EPS, tax rate |
94
+ | `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, retained earnings, shares outstanding | total liabilities, total debt, liquid assets, net debt, working capital, net working capital |
95
+ | `CashFlowStatement` | operating cash flow, capital expenditure, free cash flow, dividends paid, share repurchases | free cash flow, shareholder returns |
96
+
97
+ Outflows are positive amounts: `capital_expenditure` is what was spent.
98
+
99
+ ## Banks
100
+
101
+ Banks have no cost of goods, no inventory and no classified balance sheet, so the classic ratios come back `nil` for them and the lines that matter live on `period.banking`: net interest income, interest income, credit provisions, noninterest income and expense, loans, deposits, the allowance for credit losses and the Tier 1 capital ratio. `period.bank?` says whether the company reports net interest income.
102
+
103
+ ```ruby
104
+ jpm = Fundamentalista.company("JPM").financials.latest
105
+ jpm.bank? # => true
106
+ jpm.banking.deposits
107
+ jpm.ratios.net_interest_margin # net interest income over average total assets
108
+ jpm.ratios.efficiency_ratio # noninterest expense over net interest plus noninterest income
109
+ jpm.ratios.loan_to_deposit
110
+ jpm.ratios.cost_of_risk # credit provisions over average loans
111
+ jpm.ratios.allowance_to_loans
112
+ ```
113
+
114
+ The banking ratios appear in `ratios.to_h` only for banks, and the industrial ones that need inventory, cost of revenue or a classified balance sheet come back `nil` for them. Quarterly ratios are per quarter, not annualized; use the trailing twelve months for returns.
115
+
116
+ ## Ratios
117
+
118
+ `period.ratios` computes the classic set. Balance sheet denominators use the average of the period and the prior one when a prior is known.
119
+
120
+ | Group | Ratios |
121
+ | --- | --- |
122
+ | Profitability | `gross_margin`, `operating_margin`, `net_margin`, `ebitda_margin`, `roe`, `roa`, `roic`, `return_on_capital` |
123
+ | Liquidity | `current_ratio`, `quick_ratio`, `cash_ratio` |
124
+ | Leverage | `debt_to_equity`, `debt_to_assets`, `net_debt_to_ebitda`, `interest_coverage`, `equity_multiplier` |
125
+ | Efficiency | `asset_turnover`, `inventory_turnover`, `receivables_turnover` |
126
+ | Working capital | `days_sales_outstanding`, `days_inventory_outstanding`, `days_payables_outstanding`, `cash_conversion_cycle` |
127
+ | Cash | `fcf_margin`, `cash_conversion`, `capex_to_revenue`, `accrual_ratio` |
128
+
129
+ `return_on_capital` is Greenblatt's, operating income over net working capital plus fixed assets; pair it with the valuation's `ebit_yield` to rank companies the magic formula way. `accrual_ratio` is Sloan's, the share of earnings not backed by operating cash flow. The working capital cycle is in days of the period.
130
+
131
+ ```ruby
132
+ ratios = apple.ratios
133
+ ratios.net_margin
134
+ ratios.dupont # => {net_margin:, asset_turnover:, equity_multiplier:, roe:}
135
+ ratios.to_h
136
+ ```
137
+
138
+ ## Scores
139
+
140
+ The Piotroski F-score counts nine signals of profitability, leverage and efficiency. Six compare the period with the prior one; without a prior they report `nil` and the score counts the rest.
141
+
142
+ ```ruby
143
+ score = year.piotroski
144
+ score.score # => 8
145
+ score.signals # => {positive_income: true, cash_exceeds_income: false, ...}
146
+ ```
147
+
148
+ The Beneish M-score reads eight year-over-year indices for the fingerprints of manipulated earnings: receivables outrunning sales, melting margins, softening asset quality, accruals. Above `-1.78` it flags the company.
149
+
150
+ ```ruby
151
+ m = year.beneish
152
+ m.score # => -0.229e1
153
+ m.likely_manipulator? # => false
154
+ m.indices # => {dsri: ..., gmi: ..., aqi: ..., sgi: ..., depi: ..., sgai: ..., tata: ..., lvgi: ...}
155
+ year.owner_earnings # Buffett's: net income plus depreciation minus capital expenditure
156
+ ```
157
+
158
+ The Altman Z-score needs the market value of equity, so it lives on the valuation, or takes a market cap directly.
159
+
160
+ ```ruby
161
+ valuation.altman_z.score # => 0.122e2
162
+ year.altman_z(market_cap: 4.7e12).zone # => :safe, :grey or :distress
163
+ ```
164
+
165
+ ## Comparing companies
166
+
167
+ `Fundamentalista.compare` puts several companies side by side. Any ratio, valuation figure, score or line item goes in a table or a ranking, and `magic_formula` orders them by Greenblatt's combined rank of earnings yield and return on capital. Pass prices for EDGAR; FMP quotes on its own.
168
+
169
+ ```ruby
170
+ peers = Fundamentalista.compare("AAPL", "MSFT", "KO", prices: { "AAPL" => 320, "MSFT" => 500, "KO" => 70 })
171
+ peers.table(:roe, :net_margin, :pe, :piotroski, :altman_z)
172
+ peers.rank(:pe, ascending: true)
173
+ peers.magic_formula
174
+ ```
175
+
176
+ Tickers the provider cannot resolve are logged and left out, so one bad symbol does not spoil a screen. Absolute figures such as revenue stay in each company's reporting currency; ratios compare across currencies, amounts do not.
177
+
178
+ ## Provenance
179
+
180
+ 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.
181
+
182
+ ```ruby
183
+ year.source(:revenue)
184
+ # => #<Fundamentalista::Source concept: "RevenueFromContractWithCustomerExcludingAssessedTax", form: "10-K", filed: 2025-10-31, derivation: :reported>
185
+ year.source(:short_term_debt).derivation # => :summed
186
+ year.sources # every item
187
+ ```
188
+
189
+ ## Valuation
190
+
191
+ `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.
192
+
193
+ ```ruby
194
+ valuation = apple.valuation(price: 320)
195
+ valuation.pe # price over diluted EPS
196
+ valuation.forward_pe # price over next year's consensus EPS, or forward_pe(eps: 8.8)
197
+ valuation.pb # price over book value per share
198
+ valuation.ps # market cap over revenue
199
+ valuation.ev_to_ebitda
200
+ valuation.ev_to_ebit
201
+ valuation.peg # PE over expected EPS growth, in percent points
202
+ valuation.earnings_yield
203
+ valuation.ebit_yield # Greenblatt's: operating income over enterprise value
204
+ valuation.fcf_yield
205
+ valuation.dividend_yield
206
+ valuation.shareholder_yield # dividends plus buybacks
207
+ valuation.graham_number
208
+ valuation.to_h
209
+ ```
210
+
211
+ ### Forward multiples and estimates
212
+
213
+ Forward multiples need next year's earnings, which come from analysts, not filings. Financial Modeling Prep serves the consensus; `company.estimates` lists every year on file and `company.estimate` is the one after the latest reported period. The valuation picks it up for `forward_pe` and for `peg`, whose growth becomes the one the consensus implies. On EDGAR there are no estimates, so pass your own: `valuation.forward_pe(eps: 8.8)`.
214
+
215
+ ```ruby
216
+ estimate = company.estimate # FMP
217
+ estimate.eps # consensus average
218
+ estimate.eps_low, estimate.eps_high
219
+ estimate.analysts # how many stand behind it
220
+ estimate.eps_growth(year.income.eps_diluted)
221
+ ```
222
+
223
+ ### Discounted cash flow
224
+
225
+ `intrinsic_value` grows the period's free cash flow at `growth` for `years`, adds a terminal value at `terminal_growth`, discounts everything at `discount_rate`, subtracts net debt, and divides by the shares outstanding. `margin_of_safety` is how far the price sits below that value.
226
+
227
+ ```ruby
228
+ valuation.intrinsic_value(growth: 0.06, discount_rate: 0.09, terminal_growth: 0.025, years: 10)
229
+ valuation.margin_of_safety(growth: 0.06, discount_rate: 0.09)
230
+ valuation.implied_growth(discount_rate: 0.09) # the reverse DCF: the growth the price is paying for
231
+ ```
232
+
233
+ `implied_growth` runs the model backwards: it searches for the yearly free cash flow growth at which the intrinsic value equals the price, so you can judge whether the market's assumption is one you would make.
234
+
235
+ Pass `fade: true` to any of them and growth declines evenly from `growth` to `terminal_growth` across the projection instead of dropping at once, which is how most analysts model a company past its high growth years.
236
+
237
+ The model is also available on its own, for any cash flow stream:
238
+
239
+ ```ruby
240
+ dcf = Fundamentalista::DCF.new(cash_flow: 98_767_000_000, growth: 0.06, discount_rate: 0.09)
241
+ dcf.projection
242
+ dcf.present_value
243
+ dcf.terminal_value
244
+ dcf.value
245
+ ```
246
+
247
+ ## Providers
248
+
249
+ | | EDGAR | Financial Modeling Prep |
250
+ | --- | --- | --- |
251
+ | Cost | Free, no key | API key, free tier available |
252
+ | Periods | Annual from 10-K filings, quarterly from 10-Q filings | Annual and quarterly |
253
+ | Quotes | No, pass a price | Yes |
254
+ | Analyst estimates | No, pass an EPS | Yes |
255
+ | Coverage | Companies filing with the SEC, US GAAP and IFRS, in their reporting currency | Global |
256
+
257
+ 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.
258
+
259
+ 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.
260
+
261
+ ### Coverage
262
+
263
+ The concept map is measured, not guessed. `script/coverage` reads the latest annual period of the largest SEC filers and reports how often each line item comes back `nil`; the map grew from what the companies missing an item actually tag. Over the 500 largest filers, the items every valuation rests on are all but complete:
264
+
265
+ | Line item | Companies without it |
266
+ | --- | --- |
267
+ | net income, total liabilities, equity, cash, income tax, operating cash flow | under 2% |
268
+ | revenue | 4% |
269
+ | diluted EPS, income before tax, interest expense, depreciation and amortization, diluted shares | 5% to 7% |
270
+ | shares outstanding, PP&E, capital expenditure | 11% to 14% |
271
+
272
+ 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`.
273
+
274
+ 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.
275
+
276
+ ### Caching
277
+
278
+ An EDGAR company document runs to several megabytes, so any long-lived process should keep responses. Set `config.cache` to any store that answers `fetch(key) { value }`, such as an Active Support cache:
279
+
280
+ ```ruby
281
+ Fundamentalista.configure do |config|
282
+ config.cache = ActiveSupport::Cache::FileStore.new("tmp/fundamentalista", expires_in: 1.day)
283
+ end
284
+ ```
285
+
286
+ ## Development
287
+
288
+ ```bash
289
+ bundle install
290
+ bundle exec rake # specs and RuboCop
291
+ ```
292
+
293
+ Specs stub every provider with recorded responses; no keys are needed.
294
+
295
+ ## License
296
+
297
+ MIT.
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fundamentalista
4
+ # The balance sheet at the end of a period. +shares_outstanding+ is a
5
+ # share count, +ppe+ is property, plant and equipment net of
6
+ # depreciation; everything else is in the reporting currency.
7
+ class BalanceSheet < Statement
8
+ field :cash, :short_term_investments, :receivables, :inventory, :current_assets, :ppe, :total_assets,
9
+ :payables, :current_liabilities, :total_liabilities, :short_term_debt, :long_term_debt, :total_debt,
10
+ :equity, :retained_earnings, :shares_outstanding
11
+
12
+ # Total liabilities, derived from total assets and equity when not reported.
13
+ def total_liabilities
14
+ @total_liabilities || Decimal.subtract(total_assets, equity)
15
+ end
16
+
17
+ # Total debt, derived from short and long term debt when not reported.
18
+ # A missing short term figure counts as zero when long term debt exists.
19
+ def total_debt
20
+ @total_debt || (long_term_debt && (long_term_debt + (short_term_debt || 0)))
21
+ end
22
+
23
+ # Cash plus short term investments.
24
+ def liquid_assets
25
+ Decimal.sum(cash, short_term_investments || BigDecimal('0'))
26
+ end
27
+
28
+ # Total debt minus liquid assets; negative for a net cash position.
29
+ def net_debt
30
+ Decimal.subtract(total_debt, liquid_assets)
31
+ end
32
+
33
+ # Current assets minus current liabilities.
34
+ def working_capital
35
+ Decimal.subtract(current_assets, current_liabilities)
36
+ end
37
+
38
+ # Working capital net of cash and debt: receivables plus inventory minus
39
+ # payables, the operating capital Greenblatt's return on capital uses.
40
+ def net_working_capital
41
+ Decimal.subtract(Decimal.sum(receivables, inventory), payables)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fundamentalista
4
+ # The lines a bank reports that the three classic statements do not
5
+ # carry: interest and fee income and expense, credit provisions, loans,
6
+ # deposits and regulatory capital. Empty for companies that are not
7
+ # banks. +tier1_capital_ratio+ is a rate; everything else is in the
8
+ # reporting currency.
9
+ class Banking < Statement
10
+ field :net_interest_income, :interest_income, :provision_for_credit_losses, :noninterest_income,
11
+ :noninterest_expense, :loans, :deposits, :allowance_for_credit_losses, :tier1_capital_ratio
12
+
13
+ INSTANT = %i[loans deposits allowance_for_credit_losses tier1_capital_ratio].freeze
14
+
15
+ # Adds up +statements+, keeping the latest balances instead.
16
+ def self.sum(statements)
17
+ summed = super
18
+ balances = INSTANT.to_h { |field| [field, statements.first.public_send(field)] }
19
+ new(**summed.to_h, **balances)
20
+ end
21
+
22
+ # Whether the company reports net interest income, the mark of a bank.
23
+ def bank?
24
+ !net_interest_income.nil? && !net_interest_income.zero?
25
+ end
26
+
27
+ # Net interest income plus noninterest income, a bank's revenue.
28
+ def revenue
29
+ Decimal.sum(net_interest_income, noninterest_income)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fundamentalista
4
+ # One period of the cash flow statement. Outflows are positive amounts:
5
+ # +capital_expenditure+ is what was spent, +dividends_paid+ what was paid.
6
+ class CashFlowStatement < Statement
7
+ field :operating_cash_flow, :capital_expenditure, :free_cash_flow, :dividends_paid, :share_repurchases
8
+
9
+ # Free cash flow, derived from operating cash flow and capital
10
+ # expenditure when not reported.
11
+ def free_cash_flow
12
+ @free_cash_flow || Decimal.subtract(operating_cash_flow, capital_expenditure)
13
+ end
14
+
15
+ # Cash returned to shareholders through dividends and buybacks.
16
+ def shareholder_returns
17
+ Decimal.sum(dividends_paid || BigDecimal('0'), share_repurchases || BigDecimal('0'))
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fundamentalista
4
+ # A listed company as one provider knows it. Financial statements come
5
+ # from #financials, the market snapshot from #quote, and #valuation puts
6
+ # the two together.
7
+ #
8
+ # company = Fundamentalista.company("AAPL")
9
+ # company.name # => "Apple Inc."
10
+ # company.financials(limit: 3).latest.ratios.roe
11
+ # company.ttm.ratios.net_margin
12
+ # company.valuation(price: 320).pe
13
+ # company.estimate.eps # FMP only
14
+ #
15
+ class Company
16
+ include Inspectable
17
+
18
+ attr_reader :ticker, :name, :cik, :currency, :provider
19
+
20
+ def initialize(ticker:, name:, provider:, cik: nil, currency: 'USD')
21
+ @ticker = ticker
22
+ @name = name
23
+ @cik = cik
24
+ @currency = currency
25
+ @provider = provider
26
+ @financials = {}
27
+ end
28
+
29
+ # Returns the Financials, newest first. +period+ is +:annual+ or
30
+ # +:quarterly+ and +limit+ the number of periods.
31
+ def financials(period: :annual, limit: 5)
32
+ @financials[[period, limit]] ||= provider.financials(self, period: period, limit: limit)
33
+ end
34
+
35
+ # Returns the trailing twelve months as a Period, built from the last
36
+ # +quarters+ quarterly periods; eight give it a prior period too.
37
+ def ttm(quarters: 8)
38
+ financials(period: :quarterly, limit: quarters).ttm
39
+ end
40
+
41
+ # Returns the current Quote. Raises QuoteUnavailableError when the
42
+ # provider has no market data, as EDGAR does not.
43
+ def quote
44
+ @quote ||= provider.quote(self) || raise(QuoteUnavailableError, "#{provider.class.slug} has no quotes; pass a price")
45
+ end
46
+
47
+ # Returns the analyst Estimates the provider has, oldest first. Empty
48
+ # for EDGAR.
49
+ def estimates
50
+ @estimates ||= provider.estimates(self).sort_by(&:fiscal_year_end)
51
+ end
52
+
53
+ # Returns the Estimate for the first fiscal year ending after +after+,
54
+ # the latest reported period by default, or +nil+.
55
+ def estimate(after: latest_period.ended_on)
56
+ estimates.find { |candidate| candidate.fiscal_year_end > after }
57
+ end
58
+
59
+ # Returns the Valuation of the latest annual period at +price+, or at
60
+ # the provider's quote when no price is given, with the next fiscal
61
+ # year's Estimate when the provider has one.
62
+ def valuation(price: nil, period: latest_period)
63
+ Valuation.new(period, price ? Quote.new(price: price) : quote, estimate: estimate(after: period.ended_on))
64
+ end
65
+
66
+ # The Ratios of the latest annual period.
67
+ def ratios
68
+ latest_period.ratios
69
+ end
70
+
71
+ # The latest annual Period. Raises NoFinancialsError when the provider
72
+ # has no annual statements for the company, as with a fresh listing.
73
+ def latest_period
74
+ financials.latest || raise(NoFinancialsError, "#{provider.class.slug} has no annual statements for #{ticker}")
75
+ end
76
+
77
+ def inspect_attributes # :nodoc:
78
+ { ticker: ticker, name: name, cik: cik, provider: provider.class.slug }
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fundamentalista
4
+ # Several companies side by side: any ratio, valuation figure, score or
5
+ # line item as a table, rankings on any of them, and Greenblatt's magic
6
+ # formula ranking.
7
+ #
8
+ # peers = Fundamentalista.compare("AAPL", "MSFT", prices: { "AAPL" => 320, "MSFT" => 500 })
9
+ # peers.table(:roe, :pe, :piotroski) # => {"AAPL" => {roe: ..., pe: ..., piotroski: 8}, ...}
10
+ # peers.rank(:pe, ascending: true) # => ["MSFT", "AAPL"]
11
+ # peers.magic_formula # => ["AAPL", "MSFT"]
12
+ #
13
+ class Comparison
14
+ include Enumerable
15
+ include Inspectable
16
+
17
+ SCORES = { piotroski: ->(period, _) { period.piotroski.score },
18
+ beneish: ->(period, _) { period.beneish.score },
19
+ altman_z: ->(_, valuation) { valuation&.altman_z&.score } }.freeze
20
+
21
+ attr_reader :companies
22
+
23
+ def initialize(companies, prices: {})
24
+ @companies = companies.to_h { |company| [company.ticker, company] }
25
+ @prices = prices.transform_keys(&:to_s)
26
+ end
27
+
28
+ def each(&)
29
+ companies.each_value(&)
30
+ end
31
+
32
+ # Returns +name+ for every company, keyed by ticker. Names resolve
33
+ # against the latest period's ratios, valuation, scores, and line
34
+ # items, in that order.
35
+ def values(name)
36
+ companies.transform_values { |company| metric(company, name) }
37
+ end
38
+
39
+ # Returns every +names+ for every company, keyed by ticker.
40
+ def table(*names)
41
+ companies.transform_values { |company| names.to_h { |name| [name, metric(company, name)] } }
42
+ end
43
+
44
+ # Returns the tickers ordered by +name+, highest first unless
45
+ # +ascending+, with companies missing the figure last.
46
+ def rank(name, ascending: false)
47
+ known, unknown = values(name).partition { |_, value| value }
48
+ ordered = known.sort_by { |_, value| value }
49
+ (ascending ? ordered : ordered.reverse).map(&:first) + unknown.map(&:first)
50
+ end
51
+
52
+ # Greenblatt's magic formula: the tickers ordered by the sum of their
53
+ # rank on earnings yield and on return on capital, best first.
54
+ def magic_formula
55
+ yields = rank(:ebit_yield)
56
+ returns = rank(:return_on_capital)
57
+ companies.keys.sort_by { |ticker| yields.index(ticker) + returns.index(ticker) }
58
+ end
59
+
60
+ def inspect_attributes # :nodoc:
61
+ { companies: companies.keys }
62
+ end
63
+
64
+ private
65
+
66
+ def metric(company, name)
67
+ period = company.latest_period
68
+ valuation = valuation_of(company, period)
69
+ return period.ratios.public_send(name) if Ratios::ALL.include?(name)
70
+ return valuation&.public_send(name) if Valuation::ALL.include?(name)
71
+ return SCORES[name].call(period, valuation) if SCORES.key?(name)
72
+
73
+ period.metric(name)
74
+ end
75
+
76
+ def valuation_of(company, period)
77
+ price = @prices[company.ticker]
78
+ return company.valuation(price: price, period: period) if price
79
+
80
+ company.valuation(period: period)
81
+ rescue QuoteUnavailableError
82
+ nil
83
+ end
84
+ end
85
+ end