fundamentalista 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +21 -0
- data/lib/fundamentalista/company.rb +8 -0
- data/lib/fundamentalista/provider.rb +7 -3
- data/lib/fundamentalista/providers/edgar.rb +4 -0
- data/lib/fundamentalista/providers/fmp.rb +9 -1
- data/lib/fundamentalista/segments.rb +69 -0
- data/lib/fundamentalista/valuation.rb +32 -0
- data/lib/fundamentalista/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0d6101d9bee968831b3ffb75a7e6663deb2bb1fc925a2c01b7a4cfcfcfb50fd4
|
|
4
|
+
data.tar.gz: 52119b52c85c8bf846516846f9af7e11612bdd6c27048940007c5c798bd368e9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fc67da833989bdf483cc1fdabf382bd0f62cdd64539ae7edca030008c94f5380952224b0f83f1e2adc14651d851e2fcea8b03289e4f731a218432e8ed97f924b
|
|
7
|
+
data.tar.gz: b6125c1afde39ba4ba9298c3080ce4141ed3afeaecab18bd5a8aa1e10fefd939f68bf3f9d3d91ec8b04a1dbfb4822ad8cca5eb4512953101e057bce92d7434fa
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
- Revenue by product line and by region from FMP as `Segments`, with shares and growth per segment.
|
|
6
|
+
- Greenwald's earnings power value and the residual income model on the valuation.
|
|
7
|
+
|
|
3
8
|
## 0.3.0
|
|
4
9
|
|
|
5
10
|
- Insurance lines and ratios: premiums, claims, loss, expense and combined ratios, investment yield and the float, read from the concepts property and casualty insurers report.
|
data/README.md
CHANGED
|
@@ -285,6 +285,26 @@ company.valuation_history(:pe, :pb, :fcf_yield) # {2025 => {price:
|
|
|
285
285
|
company.valuation_history(:pe, prices: prices) # with a history you bring
|
|
286
286
|
```
|
|
287
287
|
|
|
288
|
+
## Segments
|
|
289
|
+
|
|
290
|
+
FMP serves revenue by product line and by region for every fiscal year, as `Segments`: what the company sells and where, and how each part grows. EDGAR's company facts carry no segments.
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
segments = company.segments(by: :product) # or :geography
|
|
294
|
+
segments.latest # {"iPhone" => ..., "Service" => ...}
|
|
295
|
+
segments.share # each segment's share of the latest year
|
|
296
|
+
segments.growth("Service", years: 5) # CAGR of one segment
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### More valuation models
|
|
300
|
+
|
|
301
|
+
Two models complement the DCF. Greenwald's earnings power value capitalizes today's after tax EBIT at the discount rate as if the company never grew, so the gap to the price is the growth being paid for. The residual income model starts from book value and adds only the earnings above the cost of equity's charge on it, so it rewards returns on equity above that cost and nothing else.
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
valuation.earnings_power_value(discount_rate: 0.09)
|
|
305
|
+
valuation.residual_income_value(cost_of_equity: 0.09, growth: 0.04, years: 10)
|
|
306
|
+
```
|
|
307
|
+
|
|
288
308
|
## Providers
|
|
289
309
|
|
|
290
310
|
| | EDGAR | Financial Modeling Prep |
|
|
@@ -294,6 +314,7 @@ company.valuation_history(:pe, prices: prices) # with a history y
|
|
|
294
314
|
| Quotes | No, pass a price | Yes |
|
|
295
315
|
| Analyst estimates | No, pass an EPS | Yes |
|
|
296
316
|
| Prices and beta | No, bring a PriceHistory and a beta | Yes |
|
|
317
|
+
| Revenue segments | No | Yes |
|
|
297
318
|
| Coverage | Companies filing with the SEC, US GAAP and IFRS, in their reporting currency | Global |
|
|
298
319
|
|
|
299
320
|
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.
|
|
@@ -12,6 +12,7 @@ module Fundamentalista
|
|
|
12
12
|
# company.valuation(price: 320).pe
|
|
13
13
|
# company.estimate.eps # FMP only
|
|
14
14
|
# company.valuation_history(:pe) # {2025 => {price:, pe:}, ...}
|
|
15
|
+
# company.segments(by: :geography).share # FMP only
|
|
15
16
|
#
|
|
16
17
|
class Company
|
|
17
18
|
include Inspectable
|
|
@@ -65,6 +66,13 @@ module Fundamentalista
|
|
|
65
66
|
end.to_h
|
|
66
67
|
end
|
|
67
68
|
|
|
69
|
+
# Returns the revenue Segments by +:product+ or +:geography+. Raises
|
|
70
|
+
# UnsupportedPeriodError on EDGAR, whose company facts carry none.
|
|
71
|
+
def segments(by: :product)
|
|
72
|
+
@segments ||= {}
|
|
73
|
+
@segments[by] ||= provider.segments(self, by: by)
|
|
74
|
+
end
|
|
75
|
+
|
|
68
76
|
# Returns the trailing twelve months as a Period, built from the last
|
|
69
77
|
# +quarters+ quarterly periods; eight give it a prior period too.
|
|
70
78
|
def ttm(quarters: 8)
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Fundamentalista
|
|
4
4
|
# The contract every data source implements: resolve a Company, return
|
|
5
|
-
# its Financials, a Quote or +nil+, the analyst Estimates it has,
|
|
6
|
-
# its
|
|
7
|
-
# mapping.
|
|
5
|
+
# its Financials, a Quote or +nil+, the analyst Estimates it has, its
|
|
6
|
+
# PriceHistory and its revenue Segments. Subclasses get an HTTP client
|
|
7
|
+
# with retries and error mapping.
|
|
8
8
|
class Provider
|
|
9
9
|
class << self
|
|
10
10
|
# The symbol the provider is registered under.
|
|
@@ -39,6 +39,10 @@ module Fundamentalista
|
|
|
39
39
|
raise NotImplementedError
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
def segments(company, by:)
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
end
|
|
45
|
+
|
|
42
46
|
private
|
|
43
47
|
|
|
44
48
|
def get(url, params = {}, headers: {})
|
|
@@ -44,6 +44,10 @@ module Fundamentalista
|
|
|
44
44
|
raise QuoteUnavailableError, "EDGAR has no prices for #{company.ticker}; pass a PriceHistory"
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
def segments(company, **)
|
|
48
|
+
raise UnsupportedPeriodError, "EDGAR's company facts carry no segments for #{company.ticker}"
|
|
49
|
+
end
|
|
50
|
+
|
|
47
51
|
private
|
|
48
52
|
|
|
49
53
|
STATEMENTS = { income: [IncomeStatement, Tags::INCOME], balance: [BalanceSheet, Tags::BALANCE],
|
|
@@ -4,7 +4,7 @@ module Fundamentalista
|
|
|
4
4
|
module Providers
|
|
5
5
|
# Financial Modeling Prep. Needs Configuration#fmp_api_key. Serves
|
|
6
6
|
# annual and quarterly periods, company profiles with beta, live
|
|
7
|
-
# quotes, analyst estimates and
|
|
7
|
+
# quotes, analyst estimates, daily prices and revenue segments.
|
|
8
8
|
class FMP < Provider
|
|
9
9
|
BASE_URL = 'https://financialmodelingprep.com/stable/'
|
|
10
10
|
|
|
@@ -39,6 +39,14 @@ module Fundamentalista
|
|
|
39
39
|
PriceHistory.new(points)
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
SEGMENTS = { product: 'revenue-product-segmentation', geography: 'revenue-geographic-segments' }.freeze
|
|
43
|
+
|
|
44
|
+
def segments(company, by: :product)
|
|
45
|
+
endpoint = SEGMENTS.fetch(by) { raise ArgumentError, "Segments come by :product or :geography, not #{by.inspect}" }
|
|
46
|
+
rows = fetch(endpoint, symbol: company.ticker, period: 'annual')
|
|
47
|
+
Segments.new(rows.to_h { |row| [row['fiscalYear'].to_i, row['data'] || {}] })
|
|
48
|
+
end
|
|
49
|
+
|
|
42
50
|
def estimates(company)
|
|
43
51
|
fetch('analyst-estimates', symbol: company.ticker, period: 'annual', limit: 10).map do |row|
|
|
44
52
|
Estimate.new(fiscal_year_end: Date.parse(row['date']), eps: row['epsAvg'], eps_low: row['epsLow'],
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fundamentalista
|
|
4
|
+
# Revenue by product line or by region over the years: what a company
|
|
5
|
+
# actually sells and where. Amounts are in the reporting currency,
|
|
6
|
+
# keyed by fiscal year, newest first.
|
|
7
|
+
#
|
|
8
|
+
# segments = company.segments(by: :product) # FMP
|
|
9
|
+
# segments.latest # => {"iPhone" => 0.209586e12, "Service" => 0.109158e12, ...}
|
|
10
|
+
# segments.share # => {"iPhone" => 0.503e0, ...}
|
|
11
|
+
# segments.growth("Service", years: 5)
|
|
12
|
+
#
|
|
13
|
+
class Segments
|
|
14
|
+
include Inspectable
|
|
15
|
+
include Serializable
|
|
16
|
+
|
|
17
|
+
def initialize(years)
|
|
18
|
+
@years = years.sort_by { |year, _| -year }.to_h do |year, amounts|
|
|
19
|
+
[year, amounts.transform_values do |amount|
|
|
20
|
+
Decimal.wrap(amount)
|
|
21
|
+
end]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The fiscal years on file, newest first.
|
|
26
|
+
def years
|
|
27
|
+
@years.keys
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Every segment name that appears in any year.
|
|
31
|
+
def names
|
|
32
|
+
@years.values.flat_map(&:keys).uniq
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The amounts of +year+, the latest by default.
|
|
36
|
+
def [](year)
|
|
37
|
+
@years[year]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def latest
|
|
41
|
+
@years.values.first
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns each segment's share of the year's total, as rates.
|
|
45
|
+
def share(year = years.first)
|
|
46
|
+
amounts = @years.fetch(year, {})
|
|
47
|
+
total = amounts.values.sum(BigDecimal('0'))
|
|
48
|
+
amounts.transform_values { |amount| Decimal.ratio(amount, total) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns the compound annual growth of +name+ from the year +years+
|
|
52
|
+
# back to the latest, or over the whole history by default.
|
|
53
|
+
def growth(name, years: nil)
|
|
54
|
+
span = years ? [years, self.years.size - 1].min : self.years.size - 1
|
|
55
|
+
return nil if span < 1
|
|
56
|
+
|
|
57
|
+
first = self.years[span]
|
|
58
|
+
Decimal.cagr(@years.dig(first, name), @years.dig(self.years.first, name), self.years.first - first)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def to_h
|
|
62
|
+
@years
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def inspect_attributes # :nodoc:
|
|
66
|
+
{ years: years.size, latest: years.first, segments: names.size }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -179,6 +179,38 @@ module Fundamentalista
|
|
|
179
179
|
((market_cap * equity_cost) + (debt * debt_cost)) / capital
|
|
180
180
|
end
|
|
181
181
|
|
|
182
|
+
# Greenwald's earnings power value per share: the after tax EBIT the
|
|
183
|
+
# business earns today, capitalized at the +discount_rate+ as if it
|
|
184
|
+
# never grew, net of debt. What the company is worth with no growth
|
|
185
|
+
# priced in; compare it with the price to see how much growth is.
|
|
186
|
+
def earnings_power_value(discount_rate:)
|
|
187
|
+
nopat = income.ebit && income.tax_rate && (income.ebit * (1 - income.tax_rate))
|
|
188
|
+
return nil if nopat.nil? || shares_outstanding.nil?
|
|
189
|
+
|
|
190
|
+
equity = (nopat / Decimal.wrap(discount_rate)) - (balance.net_debt || 0)
|
|
191
|
+
equity / shares_outstanding
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# The residual income value per share: book value plus the present
|
|
195
|
+
# value of the earnings above the +cost_of_equity+ charge on it, grown
|
|
196
|
+
# at +growth+ for +years+ and then fading to nothing. Rewards returns
|
|
197
|
+
# on equity above the cost of equity, and nothing else.
|
|
198
|
+
def residual_income_value(cost_of_equity:, growth: BigDecimal('0'), years: 10)
|
|
199
|
+
book = balance.equity
|
|
200
|
+
earnings = income.net_income
|
|
201
|
+
return nil if book.nil? || earnings.nil? || shares_outstanding.nil?
|
|
202
|
+
|
|
203
|
+
rate = Decimal.wrap(cost_of_equity)
|
|
204
|
+
value = book
|
|
205
|
+
(1..years).each do |year|
|
|
206
|
+
residual = earnings - (rate * book)
|
|
207
|
+
value += residual / ((1 + rate)**year)
|
|
208
|
+
earnings *= 1 + Decimal.wrap(growth)
|
|
209
|
+
book += earnings * (1 - (payout_ratio || 0))
|
|
210
|
+
end
|
|
211
|
+
value / shares_outstanding
|
|
212
|
+
end
|
|
213
|
+
|
|
182
214
|
# The reverse DCF: the yearly free cash flow growth the price implies
|
|
183
215
|
# under the given +discount_rate+ and the other DCF keywords. +nil+
|
|
184
216
|
# when no growth between -50% and +100% reproduces the price.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fundamentalista
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bruno Costanzo
|
|
@@ -105,6 +105,7 @@ files:
|
|
|
105
105
|
- lib/fundamentalista/scores/altman_z.rb
|
|
106
106
|
- lib/fundamentalista/scores/beneish.rb
|
|
107
107
|
- lib/fundamentalista/scores/piotroski.rb
|
|
108
|
+
- lib/fundamentalista/segments.rb
|
|
108
109
|
- lib/fundamentalista/serializable.rb
|
|
109
110
|
- lib/fundamentalista/source.rb
|
|
110
111
|
- lib/fundamentalista/statement.rb
|