fundamentalista 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a751de0b23e903aae3241633a34846881b375b79d30cf43f123a79e85e9ef756
4
- data.tar.gz: b5518a61ec6a452ea5cb13ff6c996301e2942e712c79dbac0b5d43b1dc143b2c
3
+ metadata.gz: 37f787ea99f5a949329fb943c8994bc1fa3cb3b9f0ed357757926b4bb661fb57
4
+ data.tar.gz: f974e488c25c1b0628e9c6fbca2400b6aa86c99705d8d274d008591a0a93d52e
5
5
  SHA512:
6
- metadata.gz: 5358331cec9f638346d55606350feaed1bf221567ae4f74495d045afe9c51d9297d2d45d09bc7816816cc9741046bd712f8e6f47b0338faf2a349155d704ed4b
7
- data.tar.gz: ef443415389f98b381bf6d756c65cfc90358dec72c68a32de6ec8034146d2aba6a4ae87d05225394f6eeec42103e093e378b97229523b1e2689ac2628ce9cea5
6
+ metadata.gz: a8f4e210ea59fac412647858620bd23f880785fb723c902770e81b295bdfd0671884affb0b1a849e165f49f0d2dc90660c5978374f673b5038b8e5ef6002cf6b
7
+ data.tar.gz: 438bf83861f879aa1c4f4b47468ffdb974ab1d3700aaa5ea8ec8375a8da891e689bc21bbc7ce11b1e50b290351342da399040e185fd5c6f06d4e2392389e46b5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - 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.
6
+
7
+ ## 0.2.0
8
+
9
+ - 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.
10
+ - The weighted average cost of capital as a discount rate built from the capital structure.
11
+
3
12
  ## 0.1.1
4
13
 
5
14
  - EBIT derived from income before tax and interest when operating income is not reported, used by interest coverage, EV/EBIT, the magic formula and the Altman Z-score.
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
 
@@ -222,6 +237,15 @@ estimate.analysts # how many stand behind it
222
237
  estimate.eps_growth(year.income.eps_diluted)
223
238
  ```
224
239
 
240
+ ### The discount rate
241
+
242
+ `wacc` builds a discount rate from the capital structure at the current price: the cost of equity by CAPM from `beta`, the risk free rate and the equity premium, and the after tax cost of debt from interest expense over total debt, weighted by market value. FMP profiles carry `company.beta`; on EDGAR pass your own.
243
+
244
+ ```ruby
245
+ rate = valuation.wacc(beta: company.beta, risk_free: 0.04, equity_premium: 0.05)
246
+ valuation.intrinsic_value(growth: 0.06, discount_rate: rate)
247
+ ```
248
+
225
249
  ### Discounted cash flow
226
250
 
227
251
  `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.
@@ -246,6 +270,21 @@ dcf.terminal_value
246
270
  dcf.value
247
271
  ```
248
272
 
273
+ ## Prices and history
274
+
275
+ FMP serves daily closes with their dividend adjusted counterparts as a `PriceHistory`; on EDGAR you build one yourself from any source. With prices, every fiscal year can be valued at the close of its last day, which is what a history of multiples is.
276
+
277
+ ```ruby
278
+ prices = company.prices(from: Date.new(2020, 1, 1)) # FMP
279
+ prices = Fundamentalista::PriceHistory.new([[Date.new(2025, 9, 26), 254.52, 254.52], ...])
280
+ prices.at(Date.new(2025, 9, 27)).close # the last close on or before that day
281
+ prices.total_return(from: Date.new(2020, 1, 1)) # dividends reinvested
282
+ prices.annualized_return
283
+
284
+ company.valuation_history(:pe, :pb, :fcf_yield) # {2025 => {price:, pe:, pb:, fcf_yield:}, ...}
285
+ company.valuation_history(:pe, prices: prices) # with a history you bring
286
+ ```
287
+
249
288
  ## Providers
250
289
 
251
290
  | | EDGAR | Financial Modeling Prep |
@@ -254,6 +293,7 @@ dcf.value
254
293
  | Periods | Annual from 10-K filings, quarterly from 10-Q filings | Annual and quarterly |
255
294
  | Quotes | No, pass a price | Yes |
256
295
  | Analyst estimates | No, pass an EPS | Yes |
296
+ | Prices and beta | No, bring a PriceHistory and a beta | Yes |
257
297
  | Coverage | Companies filing with the SEC, US GAAP and IFRS, in their reporting currency | Global |
258
298
 
259
299
  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.
@@ -11,19 +11,25 @@ module Fundamentalista
11
11
  # company.ttm.ratios.net_margin
12
12
  # company.valuation(price: 320).pe
13
13
  # company.estimate.eps # FMP only
14
+ # company.valuation_history(:pe) # {2025 => {price:, pe:}, ...}
14
15
  #
15
16
  class Company
16
17
  include Inspectable
17
18
 
18
19
  attr_reader :ticker, :name, :cik, :currency, :provider
19
20
 
20
- def initialize(ticker:, name:, provider:, cik: nil, currency: 'USD')
21
+ # The stock's beta, when the provider reports one.
22
+ attr_reader :beta
23
+
24
+ def initialize(ticker:, name:, provider:, cik: nil, currency: 'USD', beta: nil)
21
25
  @ticker = ticker
22
26
  @name = name
23
27
  @cik = cik
24
28
  @currency = currency
29
+ @beta = Decimal.wrap(beta)
25
30
  @provider = provider
26
31
  @financials = {}
32
+ @prices = {}
27
33
  end
28
34
 
29
35
  # Returns the Financials, newest first. +period+ is +:annual+ or
@@ -32,6 +38,33 @@ module Fundamentalista
32
38
  @financials[[period, limit]] ||= provider.financials(self, period: period, limit: limit)
33
39
  end
34
40
 
41
+ # Returns the PriceHistory between +from+ and +to+. Raises
42
+ # QuoteUnavailableError when the provider has no prices, as EDGAR does
43
+ # not; build a PriceHistory yourself there.
44
+ def prices(from:, to: Date.today)
45
+ @prices[[from, to]] ||= provider.prices(self, from: from, to: to)
46
+ end
47
+
48
+ # Returns +metrics+ of the Valuation of every annual period at the
49
+ # close of its last day, keyed by fiscal year, newest first. Takes the
50
+ # provider's +prices+ unless a PriceHistory is given. Years without a
51
+ # price are left out.
52
+ #
53
+ # company.valuation_history(:pe, :fcf_yield)
54
+ # # => {2025 => {price: 0.25452e3, pe: 0.3412e2, fcf_yield: 0.026e-1}, ...}
55
+ #
56
+ def valuation_history(*metrics, prices: nil)
57
+ periods = financials.to_a
58
+ prices ||= self.prices(from: periods.last.ended_on)
59
+ periods.filter_map do |period|
60
+ price = prices.at(period.ended_on)
61
+ next unless price
62
+
63
+ valuation = Valuation.new(period, Quote.new(price: price.close))
64
+ [period.fiscal_year, { price: price.close, **metrics.to_h { |metric| [metric, valuation.public_send(metric)] } }]
65
+ end.to_h
66
+ end
67
+
35
68
  # Returns the trailing twelve months as a Period, built from the last
36
69
  # +quarters+ quarterly periods; eight give it a prior period too.
37
70
  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, sources: {},
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)
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fundamentalista
4
+ # Daily closes over a span of time, oldest first. Each Price carries the
5
+ # close and the dividend adjusted close, so multiples use the price of
6
+ # the day and returns include what was paid out.
7
+ #
8
+ # prices = company.prices(from: Date.new(2020, 1, 1)) # FMP
9
+ # prices = Fundamentalista::PriceHistory.new([[Date.new(2025, 9, 26), 254.52, 254.52], ...])
10
+ # prices.at(Date.new(2025, 9, 27)).close # the last close on or before that day
11
+ # prices.total_return(from: Date.new(2020, 1, 1)) # dividends reinvested
12
+ #
13
+ class PriceHistory
14
+ include Enumerable
15
+ include Inspectable
16
+ include Serializable
17
+
18
+ # One day's close and dividend adjusted close.
19
+ Price = Struct.new(:date, :close, :adjusted, keyword_init: true) do
20
+ include Serializable
21
+
22
+ def to_h
23
+ { date: date, close: close, adjusted: adjusted }
24
+ end
25
+ end
26
+
27
+ def initialize(points)
28
+ @prices = points.map { |point| wrap(point) }.sort_by(&:date)
29
+ end
30
+
31
+ def each(&)
32
+ @prices.each(&)
33
+ end
34
+
35
+ def size
36
+ @prices.size
37
+ end
38
+
39
+ def from
40
+ @prices.first&.date
41
+ end
42
+
43
+ def to
44
+ @prices.last&.date
45
+ end
46
+
47
+ # Returns the Price of the last trading day on or before +date+, or +nil+.
48
+ def at(date)
49
+ @prices.reverse_each.find { |price| price.date <= date }
50
+ end
51
+
52
+ # Returns the return with dividends reinvested between +from+ and +to+,
53
+ # the whole history by default, as a rate, or +nil+ when either day
54
+ # has no price.
55
+ def total_return(from: self.from, to: self.to)
56
+ start = at(from)
57
+ finish = at(to)
58
+ return nil unless start && finish
59
+
60
+ Decimal.ratio(finish.adjusted, start.adjusted) - 1
61
+ end
62
+
63
+ # Returns the total return between +from+ and +to+ annualized.
64
+ def annualized_return(from: self.from, to: self.to)
65
+ start = at(from)
66
+ finish = at(to)
67
+ return nil unless start && finish
68
+
69
+ Decimal.cagr(start.adjusted, finish.adjusted, (finish.date - start.date).to_f / 365.25)
70
+ end
71
+
72
+ def to_h
73
+ { prices: @prices.map(&:to_h) }
74
+ end
75
+
76
+ def inspect_attributes # :nodoc:
77
+ { from: from, to: to, days: size }
78
+ end
79
+
80
+ private
81
+
82
+ def wrap(point)
83
+ return point if point.is_a?(Price)
84
+
85
+ date, close, adjusted = point
86
+ Price.new(date: date.is_a?(Date) ? date : Date.parse(date.to_s), close: Decimal.wrap(close),
87
+ adjusted: Decimal.wrap(adjusted || close))
88
+ end
89
+ end
90
+ end
@@ -2,8 +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+, and the analyst Estimates it has.
6
- # Subclasses get an HTTP client with retries and error mapping.
5
+ # its Financials, a Quote or +nil+, the analyst Estimates it has, and
6
+ # its PriceHistory. Subclasses get an HTTP client with retries and error
7
+ # mapping.
7
8
  class Provider
8
9
  class << self
9
10
  # The symbol the provider is registered under.
@@ -34,6 +35,10 @@ module Fundamentalista
34
35
  raise NotImplementedError
35
36
  end
36
37
 
38
+ def prices(company, from:, to:)
39
+ raise NotImplementedError
40
+ end
41
+
37
42
  private
38
43
 
39
44
  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
- INSTANT = (BALANCE.keys + Banking::INSTANT).freeze
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
@@ -40,10 +40,15 @@ module Fundamentalista
40
40
  []
41
41
  end
42
42
 
43
+ def prices(company, **)
44
+ raise QuoteUnavailableError, "EDGAR has no prices for #{company.ticker}; pass a PriceHistory"
45
+ end
46
+
43
47
  private
44
48
 
45
49
  STATEMENTS = { income: [IncomeStatement, Tags::INCOME], balance: [BalanceSheet, Tags::BALANCE],
46
- cash_flow: [CashFlowStatement, Tags::CASH_FLOW], banking: [Banking, Tags::BANKING] }.freeze
50
+ cash_flow: [CashFlowStatement, Tags::CASH_FLOW], banking: [Banking, Tags::BANKING],
51
+ insurance: [Insurance, Tags::INSURANCE] }.freeze
47
52
  private_constant :STATEMENTS
48
53
 
49
54
  def annual_period(facts, year, ended_on)
@@ -3,8 +3,8 @@
3
3
  module Fundamentalista
4
4
  module Providers
5
5
  # Financial Modeling Prep. Needs Configuration#fmp_api_key. Serves
6
- # annual and quarterly periods, company profiles, live quotes and
7
- # analyst estimates.
6
+ # annual and quarterly periods, company profiles with beta, live
7
+ # quotes, analyst estimates and daily prices.
8
8
  class FMP < Provider
9
9
  BASE_URL = 'https://financialmodelingprep.com/stable/'
10
10
 
@@ -13,7 +13,7 @@ module Fundamentalista
13
13
  raise CompanyNotFoundError, "FMP lists no company under #{ticker.inspect}" unless profile
14
14
 
15
15
  Company.new(ticker: profile['symbol'], name: profile['companyName'], cik: profile['cik']&.to_i,
16
- currency: profile['currency'], provider: self)
16
+ currency: profile['currency'], beta: profile['beta'], provider: self)
17
17
  end
18
18
 
19
19
  def financials(company, period: :annual, limit: 5)
@@ -32,6 +32,13 @@ module Fundamentalista
32
32
  as_of: row['timestamp'] && Time.at(row['timestamp']))
33
33
  end
34
34
 
35
+ def prices(company, from:, to:)
36
+ params = { symbol: company.ticker, from: from.iso8601, to: to.iso8601 }
37
+ adjusted = fetch('historical-price-eod/dividend-adjusted', params).to_h { |row| [row['date'], row['adjClose']] }
38
+ points = fetch('historical-price-eod/light', params).map { |row| [row['date'], row['price'], adjusted[row['date']]] }
39
+ PriceHistory.new(points)
40
+ end
41
+
35
42
  def estimates(company)
36
43
  fetch('analyst-estimates', symbol: company.ticker, period: 'annual', limit: 10).map do |row|
37
44
  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
- ALL = (PROFITABILITY + LIQUIDITY + LEVERAGE + EFFICIENCY + WORKING_CAPITAL + CASH + BANKING).freeze
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? ? ALL : ALL - BANKING
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
@@ -151,6 +151,34 @@ module Fundamentalista
151
151
  Decimal.ratio(Decimal.subtract(value, price), value)
152
152
  end
153
153
 
154
+ # The cost of equity by the capital asset pricing model: the risk free
155
+ # rate plus +beta+ times the +equity_premium+. Rates are decimals.
156
+ def cost_of_equity(beta:, risk_free:, equity_premium: BigDecimal('0.05'))
157
+ Decimal.wrap(risk_free) + (Decimal.wrap(beta) * Decimal.wrap(equity_premium))
158
+ end
159
+
160
+ # The pre-tax cost of debt: interest expense over total debt, or the
161
+ # +cost_of_debt+ you pass.
162
+ def cost_of_debt(cost_of_debt: nil)
163
+ cost_of_debt ? Decimal.wrap(cost_of_debt) : Decimal.ratio(income.interest_expense, balance.total_debt)
164
+ end
165
+
166
+ # The weighted average cost of capital at this market capitalization:
167
+ # equity at its CAPM cost, debt at its after tax cost, weighted by
168
+ # market value. A ready discount rate for #intrinsic_value.
169
+ #
170
+ # valuation.intrinsic_value(growth: 0.06, discount_rate: valuation.wacc(beta: 1.1, risk_free: 0.04))
171
+ #
172
+ def wacc(beta:, risk_free:, equity_premium: BigDecimal('0.05'), cost_of_debt: nil)
173
+ debt = balance.total_debt || BigDecimal('0')
174
+ capital = Decimal.sum(market_cap, debt)
175
+ return nil if capital.nil? || capital.zero?
176
+
177
+ equity_cost = cost_of_equity(beta: beta, risk_free: risk_free, equity_premium: equity_premium)
178
+ debt_cost = (self.cost_of_debt(cost_of_debt: cost_of_debt) || BigDecimal('0')) * (1 - (income.tax_rate || BigDecimal('0')))
179
+ ((market_cap * equity_cost) + (debt * debt_cost)) / capital
180
+ end
181
+
154
182
  # The reverse DCF: the yearly free cash flow growth the price implies
155
183
  # under the given +discount_rate+ and the other DCF keywords. +nil+
156
184
  # when no growth between -50% and +100% reproduces the price.
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Fundamentalista
4
4
  # The version of the fundamentalista gem, as a string.
5
- VERSION = '0.1.1'
5
+ VERSION = '0.3.0'
6
6
  end
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.1.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bruno Costanzo
@@ -92,7 +92,9 @@ 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
97
+ - lib/fundamentalista/price_history.rb
96
98
  - lib/fundamentalista/provider.rb
97
99
  - lib/fundamentalista/providers/edgar.rb
98
100
  - lib/fundamentalista/providers/edgar/facts.rb