fundamentalista 0.1.1 → 0.2.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: 36260d14ca8a99975cc3c9717ecd1fa5694f2e2d29144aaeb8d51e9eed3a25a5
4
+ data.tar.gz: 920282a4dd650e6ed172638e02b85c37b3ac6e0166af5f7a837b4e69003d1c59
5
5
  SHA512:
6
- metadata.gz: 5358331cec9f638346d55606350feaed1bf221567ae4f74495d045afe9c51d9297d2d45d09bc7816816cc9741046bd712f8e6f47b0338faf2a349155d704ed4b
7
- data.tar.gz: ef443415389f98b381bf6d756c65cfc90358dec72c68a32de6ec8034146d2aba6a4ae87d05225394f6eeec42103e093e378b97229523b1e2689ac2628ce9cea5
6
+ metadata.gz: 698002a96de44e7c842e619fbce287758f2478f56f0e0a3c1fcbb131a635e88316b9609bfba710c7223627f6ea7ba67b5a3ad5cc5a9296cf34b061a65b76956f
7
+ data.tar.gz: 675e708b8b952f95aa50ef374165b37fa70f9d7d6e217ee8b14fb7a8157fec4c1402f8a0703aba8d4808fa33e0202a75eeafc43e2fd76a8bdba358b63f044763
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ - 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.
6
+ - The weighted average cost of capital as a discount rate built from the capital structure.
7
+
3
8
  ## 0.1.1
4
9
 
5
10
  - 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
@@ -222,6 +222,15 @@ estimate.analysts # how many stand behind it
222
222
  estimate.eps_growth(year.income.eps_diluted)
223
223
  ```
224
224
 
225
+ ### The discount rate
226
+
227
+ `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.
228
+
229
+ ```ruby
230
+ rate = valuation.wacc(beta: company.beta, risk_free: 0.04, equity_premium: 0.05)
231
+ valuation.intrinsic_value(growth: 0.06, discount_rate: rate)
232
+ ```
233
+
225
234
  ### Discounted cash flow
226
235
 
227
236
  `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 +255,21 @@ dcf.terminal_value
246
255
  dcf.value
247
256
  ```
248
257
 
258
+ ## Prices and history
259
+
260
+ 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.
261
+
262
+ ```ruby
263
+ prices = company.prices(from: Date.new(2020, 1, 1)) # FMP
264
+ prices = Fundamentalista::PriceHistory.new([[Date.new(2025, 9, 26), 254.52, 254.52], ...])
265
+ prices.at(Date.new(2025, 9, 27)).close # the last close on or before that day
266
+ prices.total_return(from: Date.new(2020, 1, 1)) # dividends reinvested
267
+ prices.annualized_return
268
+
269
+ company.valuation_history(:pe, :pb, :fcf_yield) # {2025 => {price:, pe:, pb:, fcf_yield:}, ...}
270
+ company.valuation_history(:pe, prices: prices) # with a history you bring
271
+ ```
272
+
249
273
  ## Providers
250
274
 
251
275
  | | EDGAR | Financial Modeling Prep |
@@ -254,6 +278,7 @@ dcf.value
254
278
  | Periods | Annual from 10-K filings, quarterly from 10-Q filings | Annual and quarterly |
255
279
  | Quotes | No, pass a price | Yes |
256
280
  | Analyst estimates | No, pass an EPS | Yes |
281
+ | Prices and beta | No, bring a PriceHistory and a beta | Yes |
257
282
  | Coverage | Companies filing with the SEC, US GAAP and IFRS, in their reporting currency | Global |
258
283
 
259
284
  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)
@@ -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: {})
@@ -40,6 +40,10 @@ 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],
@@ -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'],
@@ -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.2.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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bruno Costanzo
@@ -93,6 +93,7 @@ files:
93
93
  - lib/fundamentalista/income_statement.rb
94
94
  - lib/fundamentalista/inspectable.rb
95
95
  - lib/fundamentalista/period.rb
96
+ - lib/fundamentalista/price_history.rb
96
97
  - lib/fundamentalista/provider.rb
97
98
  - lib/fundamentalista/providers/edgar.rb
98
99
  - lib/fundamentalista/providers/edgar/facts.rb