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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE +21 -0
- data/README.md +297 -0
- data/lib/fundamentalista/balance_sheet.rb +44 -0
- data/lib/fundamentalista/banking.rb +32 -0
- data/lib/fundamentalista/cash_flow_statement.rb +20 -0
- data/lib/fundamentalista/company.rb +81 -0
- data/lib/fundamentalista/comparison.rb +85 -0
- data/lib/fundamentalista/configuration.rb +71 -0
- data/lib/fundamentalista/dcf.rb +73 -0
- data/lib/fundamentalista/decimal.rb +57 -0
- data/lib/fundamentalista/error.rb +23 -0
- data/lib/fundamentalista/estimate.rb +38 -0
- data/lib/fundamentalista/financials.rb +103 -0
- data/lib/fundamentalista/income_statement.rb +40 -0
- data/lib/fundamentalista/inspectable.rb +26 -0
- data/lib/fundamentalista/period.rb +163 -0
- data/lib/fundamentalista/provider.rb +70 -0
- data/lib/fundamentalista/providers/edgar/facts.rb +172 -0
- data/lib/fundamentalista/providers/edgar/tags.rb +116 -0
- data/lib/fundamentalista/providers/edgar.rb +109 -0
- data/lib/fundamentalista/providers/fmp.rb +109 -0
- data/lib/fundamentalista/quote.rb +32 -0
- data/lib/fundamentalista/ratios.rb +231 -0
- data/lib/fundamentalista/scores/altman_z.rb +69 -0
- data/lib/fundamentalista/scores/beneish.rb +112 -0
- data/lib/fundamentalista/scores/piotroski.rb +111 -0
- data/lib/fundamentalista/serializable.rb +34 -0
- data/lib/fundamentalista/source.rb +27 -0
- data/lib/fundamentalista/statement.rb +49 -0
- data/lib/fundamentalista/valuation.rb +201 -0
- data/lib/fundamentalista/version.rb +6 -0
- data/lib/fundamentalista.rb +85 -0
- 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
|
+
[](https://rubygems.org/gems/fundamentalista) [](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
|