fundamentalista 0.2.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 +9 -0
- data/README.md +37 -1
- data/lib/fundamentalista/company.rb +8 -0
- data/lib/fundamentalista/financials.rb +2 -1
- data/lib/fundamentalista/insurance.rb +35 -0
- data/lib/fundamentalista/period.rb +14 -4
- data/lib/fundamentalista/provider.rb +7 -3
- data/lib/fundamentalista/providers/edgar/tags.rb +16 -1
- data/lib/fundamentalista/providers/edgar.rb +6 -1
- data/lib/fundamentalista/providers/fmp.rb +9 -1
- data/lib/fundamentalista/ratios.rb +35 -3
- data/lib/fundamentalista/segments.rb +69 -0
- data/lib/fundamentalista/valuation.rb +32 -0
- data/lib/fundamentalista/version.rb +1 -1
- metadata +3 -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,14 @@
|
|
|
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
|
+
|
|
8
|
+
## 0.3.0
|
|
9
|
+
|
|
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.
|
|
11
|
+
|
|
3
12
|
## 0.2.0
|
|
4
13
|
|
|
5
14
|
- Daily prices from FMP as a `PriceHistory` with dividend adjusted returns, the valuation of every fiscal year at its closing price, and the company beta.
|
data/README.md
CHANGED
|
@@ -113,7 +113,22 @@ jpm.ratios.cost_of_risk # credit provisions over average loans
|
|
|
113
113
|
jpm.ratios.allowance_to_loans
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
-
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.
|
|
116
|
+
The banking and insurance ratios appear in `ratios.to_h` only for banks and insurers, 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.
|
|
117
|
+
|
|
118
|
+
## Insurers
|
|
119
|
+
|
|
120
|
+
Insurers earn premiums and invest the float, so their lines live on `period.insurance`: premiums earned and written, claims incurred, total benefits and expenses, investment income, investments, reserves, unearned premiums, premiums receivable and reinsurance recoverables. `period.insurer?` says whether the company earns premiums, and `insurance.float` is Buffett's: reserves plus unearned premiums, less what policyholders and reinsurers still owe.
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
trv = Fundamentalista.company("TRV").financials.latest
|
|
124
|
+
trv.ratios.loss_ratio # claims incurred over premiums earned
|
|
125
|
+
trv.ratios.combined_ratio # benefits, losses and expenses over premiums earned; below one, underwriting makes money
|
|
126
|
+
trv.ratios.expense_ratio # the difference between the two
|
|
127
|
+
trv.ratios.investment_yield # investment income over average investments
|
|
128
|
+
trv.ratios.float_to_equity
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
These are property and casualty concepts. For life insurers, whose revenue is mostly investment income and whose reserves are future policy benefits, the combined ratio and the float mislead; read their investment yield and return on equity instead.
|
|
117
132
|
|
|
118
133
|
## Ratios
|
|
119
134
|
|
|
@@ -270,6 +285,26 @@ company.valuation_history(:pe, :pb, :fcf_yield) # {2025 => {price:
|
|
|
270
285
|
company.valuation_history(:pe, prices: prices) # with a history you bring
|
|
271
286
|
```
|
|
272
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
|
+
|
|
273
308
|
## Providers
|
|
274
309
|
|
|
275
310
|
| | EDGAR | Financial Modeling Prep |
|
|
@@ -279,6 +314,7 @@ company.valuation_history(:pe, prices: prices) # with a history y
|
|
|
279
314
|
| Quotes | No, pass a price | Yes |
|
|
280
315
|
| Analyst estimates | No, pass an EPS | Yes |
|
|
281
316
|
| Prices and beta | No, bring a PriceHistory and a beta | Yes |
|
|
317
|
+
| Revenue segments | No | Yes |
|
|
282
318
|
| Coverage | Companies filing with the SEC, US GAAP and IFRS, in their reporting currency | Global |
|
|
283
319
|
|
|
284
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)
|
|
@@ -92,7 +92,8 @@ module Fundamentalista
|
|
|
92
92
|
currency: quarters.first.currency, balance: quarters.first.balance,
|
|
93
93
|
income: IncomeStatement.sum(quarters.map(&:income)),
|
|
94
94
|
cash_flow: CashFlowStatement.sum(quarters.map(&:cash_flow)),
|
|
95
|
-
banking: Banking.sum(quarters.map(&:banking))
|
|
95
|
+
banking: Banking.sum(quarters.map(&:banking)),
|
|
96
|
+
insurance: Insurance.sum(quarters.map(&:insurance))
|
|
96
97
|
)
|
|
97
98
|
end
|
|
98
99
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fundamentalista
|
|
4
|
+
# The lines an insurer reports that the three classic statements do not
|
|
5
|
+
# carry: premiums, claims, investment income, reserves and the balances
|
|
6
|
+
# behind the float. Empty for companies that are not insurers. All in
|
|
7
|
+
# the reporting currency.
|
|
8
|
+
class Insurance < Statement
|
|
9
|
+
field :premiums_earned, :premiums_written, :claims_incurred, :benefits_and_expenses, :investment_income,
|
|
10
|
+
:investments, :reserves, :unearned_premiums, :premiums_receivable, :reinsurance_recoverables
|
|
11
|
+
|
|
12
|
+
INSTANT = %i[investments reserves unearned_premiums premiums_receivable reinsurance_recoverables].freeze
|
|
13
|
+
|
|
14
|
+
# Adds up +statements+, keeping the latest balances instead.
|
|
15
|
+
def self.sum(statements)
|
|
16
|
+
summed = super
|
|
17
|
+
balances = INSTANT.to_h { |field| [field, statements.first.public_send(field)] }
|
|
18
|
+
new(**summed.to_h, **balances)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Whether the company earns premiums, the mark of an insurer.
|
|
22
|
+
def insurer?
|
|
23
|
+
!premiums_earned.nil? && !premiums_earned.zero?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Buffett's float: reserves and unearned premiums, less what
|
|
27
|
+
# policyholders and reinsurers still owe. The money an insurer invests
|
|
28
|
+
# before it pays claims.
|
|
29
|
+
def float
|
|
30
|
+
return nil if reserves.nil?
|
|
31
|
+
|
|
32
|
+
reserves + (unearned_premiums || 0) - (premiums_receivable || 0) - (reinsurance_recoverables || 0)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -27,6 +27,9 @@ module Fundamentalista
|
|
|
27
27
|
# The Banking lines, empty for companies that are not banks.
|
|
28
28
|
attr_reader :banking
|
|
29
29
|
|
|
30
|
+
# The Insurance lines, empty for companies that are not insurers.
|
|
31
|
+
attr_reader :insurance
|
|
32
|
+
|
|
30
33
|
# Where each line item came from, keyed by item, as Source objects.
|
|
31
34
|
# Empty when the provider does not say.
|
|
32
35
|
attr_reader :sources
|
|
@@ -37,14 +40,15 @@ module Fundamentalista
|
|
|
37
40
|
# The period before this one, or +nil+.
|
|
38
41
|
attr_accessor :prior
|
|
39
42
|
|
|
40
|
-
def initialize(fiscal_year:, ended_on:, income:, balance:, cash_flow:, banking: Banking.new,
|
|
41
|
-
type: :annual, quarter: nil, currency: 'USD', prior: nil)
|
|
43
|
+
def initialize(fiscal_year:, ended_on:, income:, balance:, cash_flow:, banking: Banking.new, insurance: Insurance.new,
|
|
44
|
+
sources: {}, type: :annual, quarter: nil, currency: 'USD', prior: nil)
|
|
42
45
|
@fiscal_year = fiscal_year
|
|
43
46
|
@ended_on = ended_on
|
|
44
47
|
@income = income
|
|
45
48
|
@balance = balance
|
|
46
49
|
@cash_flow = cash_flow
|
|
47
50
|
@banking = banking
|
|
51
|
+
@insurance = insurance
|
|
48
52
|
@sources = sources
|
|
49
53
|
@type = type
|
|
50
54
|
@quarter = quarter
|
|
@@ -69,6 +73,11 @@ module Fundamentalista
|
|
|
69
73
|
banking.bank?
|
|
70
74
|
end
|
|
71
75
|
|
|
76
|
+
# Whether the company is an insurer, judged by premiums earned.
|
|
77
|
+
def insurer?
|
|
78
|
+
insurance.insurer?
|
|
79
|
+
end
|
|
80
|
+
|
|
72
81
|
# Returns the Source of the line item +name+, or +nil+.
|
|
73
82
|
def source(name)
|
|
74
83
|
sources[name]
|
|
@@ -90,7 +99,8 @@ module Fundamentalista
|
|
|
90
99
|
# Returns the period and its statements as a Hash.
|
|
91
100
|
def to_h
|
|
92
101
|
{ fiscal_year: fiscal_year, quarter: quarter, type: type, ended_on: ended_on, currency: currency,
|
|
93
|
-
income: income.to_h, balance: balance.to_h, cash_flow: cash_flow.to_h, banking: banking.to_h
|
|
102
|
+
income: income.to_h, balance: balance.to_h, cash_flow: cash_flow.to_h, banking: banking.to_h,
|
|
103
|
+
insurance: insurance.to_h }
|
|
94
104
|
end
|
|
95
105
|
|
|
96
106
|
# The days the period spans, for turning turnover into days.
|
|
@@ -136,7 +146,7 @@ module Fundamentalista
|
|
|
136
146
|
return owner_earnings if name == :owner_earnings
|
|
137
147
|
return per_share(name.to_s.delete_suffix('_per_share').to_sym) if name.end_with?('_per_share')
|
|
138
148
|
|
|
139
|
-
statement = [income, balance, cash_flow, banking].find { |candidate| candidate.respond_to?(name) }
|
|
149
|
+
statement = [income, balance, cash_flow, banking, insurance].find { |candidate| candidate.respond_to?(name) }
|
|
140
150
|
raise ArgumentError, "Unknown metric #{name.inspect}" unless statement
|
|
141
151
|
|
|
142
152
|
statement.public_send(name)
|
|
@@ -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: {})
|
|
@@ -111,7 +111,22 @@ module Fundamentalista
|
|
|
111
111
|
# by fiscal year end, used when the balance sheet lacks the item.
|
|
112
112
|
COVER = { shares_outstanding: 'EntityCommonStockSharesOutstanding' }.freeze
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
INSURANCE = {
|
|
115
|
+
premiums_earned: %w[PremiumsEarnedNet DirectPremiumsEarned],
|
|
116
|
+
premiums_written: %w[PremiumsWrittenNet DirectPremiumsWritten],
|
|
117
|
+
claims_incurred: %w[PolicyholderBenefitsAndClaimsIncurredNet
|
|
118
|
+
LiabilityForUnpaidClaimsAndClaimsAdjustmentExpenseIncurredClaims1],
|
|
119
|
+
benefits_and_expenses: %w[BenefitsLossesAndExpenses],
|
|
120
|
+
investment_income: %w[NetInvestmentIncome GrossInvestmentIncomeOperating],
|
|
121
|
+
investments: %w[Investments],
|
|
122
|
+
reserves: %w[LiabilityForFuturePolicyBenefitsAndUnpaidClaimsAndClaimsAdjustmentExpense
|
|
123
|
+
LiabilityForClaimsAndClaimsAdjustmentExpense LiabilityForFuturePolicyBenefits],
|
|
124
|
+
unearned_premiums: %w[UnearnedPremiums],
|
|
125
|
+
premiums_receivable: %w[PremiumsReceivableAtCarryingValue],
|
|
126
|
+
reinsurance_recoverables: %w[ReinsuranceRecoverablesOnPaidAndUnpaidLosses]
|
|
127
|
+
}.freeze
|
|
128
|
+
|
|
129
|
+
INSTANT = (BALANCE.keys + Banking::INSTANT + Insurance::INSTANT).freeze
|
|
115
130
|
end
|
|
116
131
|
end
|
|
117
132
|
end
|
|
@@ -44,10 +44,15 @@ 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],
|
|
50
|
-
cash_flow: [CashFlowStatement, Tags::CASH_FLOW], banking: [Banking, Tags::BANKING]
|
|
54
|
+
cash_flow: [CashFlowStatement, Tags::CASH_FLOW], banking: [Banking, Tags::BANKING],
|
|
55
|
+
insurance: [Insurance, Tags::INSURANCE] }.freeze
|
|
51
56
|
private_constant :STATEMENTS
|
|
52
57
|
|
|
53
58
|
def annual_period(facts, year, ended_on)
|
|
@@ -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'],
|
|
@@ -24,7 +24,8 @@ module Fundamentalista
|
|
|
24
24
|
cash_conversion_cycle].freeze
|
|
25
25
|
CASH = %i[fcf_margin cash_conversion capex_to_revenue accrual_ratio].freeze
|
|
26
26
|
BANKING = %i[net_interest_margin efficiency_ratio loan_to_deposit cost_of_risk allowance_to_loans].freeze
|
|
27
|
-
|
|
27
|
+
INSURANCE = %i[loss_ratio expense_ratio combined_ratio investment_yield float_to_equity].freeze
|
|
28
|
+
ALL = (PROFITABILITY + LIQUIDITY + LEVERAGE + EFFICIENCY + WORKING_CAPITAL + CASH + BANKING + INSURANCE).freeze
|
|
28
29
|
|
|
29
30
|
attr_reader :period
|
|
30
31
|
|
|
@@ -182,15 +183,42 @@ module Fundamentalista
|
|
|
182
183
|
Decimal.ratio(banking.allowance_for_credit_losses, banking.loans)
|
|
183
184
|
end
|
|
184
185
|
|
|
186
|
+
# Claims incurred over premiums earned.
|
|
187
|
+
def loss_ratio
|
|
188
|
+
Decimal.ratio(insurance.claims_incurred, insurance.premiums_earned)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Everything the insurer spends beyond claims, over premiums earned.
|
|
192
|
+
def expense_ratio
|
|
193
|
+
Decimal.subtract(combined_ratio, loss_ratio)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Benefits, losses and expenses over premiums earned; below one the
|
|
197
|
+
# underwriting itself makes money.
|
|
198
|
+
def combined_ratio
|
|
199
|
+
Decimal.ratio(insurance.benefits_and_expenses, insurance.premiums_earned)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Net investment income over average investments.
|
|
203
|
+
def investment_yield
|
|
204
|
+
Decimal.ratio(insurance.investment_income, average(:investments, of: :insurance))
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# The float over equity, the leverage an insurer's investing runs on.
|
|
208
|
+
def float_to_equity
|
|
209
|
+
Decimal.ratio(insurance.float, balance.equity)
|
|
210
|
+
end
|
|
211
|
+
|
|
185
212
|
# The DuPont decomposition of ROE: net margin times asset turnover times
|
|
186
213
|
# the equity multiplier.
|
|
187
214
|
def dupont
|
|
188
215
|
{ net_margin: net_margin, asset_turnover: asset_turnover, equity_multiplier: equity_multiplier, roe: roe }
|
|
189
216
|
end
|
|
190
217
|
|
|
191
|
-
# Returns every ratio keyed by name; the banking ones only for banks
|
|
218
|
+
# Returns every ratio keyed by name; the banking ones only for banks,
|
|
219
|
+
# the insurance ones only for insurers.
|
|
192
220
|
def to_h
|
|
193
|
-
names = period.bank? ?
|
|
221
|
+
names = ALL - (period.bank? ? [] : BANKING) - (period.insurer? ? [] : INSURANCE)
|
|
194
222
|
names.to_h { |name| [name, public_send(name)] }
|
|
195
223
|
end
|
|
196
224
|
|
|
@@ -216,6 +244,10 @@ module Fundamentalista
|
|
|
216
244
|
period.banking
|
|
217
245
|
end
|
|
218
246
|
|
|
247
|
+
def insurance
|
|
248
|
+
period.insurance
|
|
249
|
+
end
|
|
250
|
+
|
|
219
251
|
def average(field, of: :balance)
|
|
220
252
|
current = period.public_send(of).public_send(field)
|
|
221
253
|
return current unless period.prior
|
|
@@ -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
|
|
@@ -92,6 +92,7 @@ files:
|
|
|
92
92
|
- lib/fundamentalista/financials.rb
|
|
93
93
|
- lib/fundamentalista/income_statement.rb
|
|
94
94
|
- lib/fundamentalista/inspectable.rb
|
|
95
|
+
- lib/fundamentalista/insurance.rb
|
|
95
96
|
- lib/fundamentalista/period.rb
|
|
96
97
|
- lib/fundamentalista/price_history.rb
|
|
97
98
|
- lib/fundamentalista/provider.rb
|
|
@@ -104,6 +105,7 @@ files:
|
|
|
104
105
|
- lib/fundamentalista/scores/altman_z.rb
|
|
105
106
|
- lib/fundamentalista/scores/beneish.rb
|
|
106
107
|
- lib/fundamentalista/scores/piotroski.rb
|
|
108
|
+
- lib/fundamentalista/segments.rb
|
|
107
109
|
- lib/fundamentalista/serializable.rb
|
|
108
110
|
- lib/fundamentalista/source.rb
|
|
109
111
|
- lib/fundamentalista/statement.rb
|