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.
Files changed (35) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +15 -0
  3. data/LICENSE +21 -0
  4. data/README.md +297 -0
  5. data/lib/fundamentalista/balance_sheet.rb +44 -0
  6. data/lib/fundamentalista/banking.rb +32 -0
  7. data/lib/fundamentalista/cash_flow_statement.rb +20 -0
  8. data/lib/fundamentalista/company.rb +81 -0
  9. data/lib/fundamentalista/comparison.rb +85 -0
  10. data/lib/fundamentalista/configuration.rb +71 -0
  11. data/lib/fundamentalista/dcf.rb +73 -0
  12. data/lib/fundamentalista/decimal.rb +57 -0
  13. data/lib/fundamentalista/error.rb +23 -0
  14. data/lib/fundamentalista/estimate.rb +38 -0
  15. data/lib/fundamentalista/financials.rb +103 -0
  16. data/lib/fundamentalista/income_statement.rb +40 -0
  17. data/lib/fundamentalista/inspectable.rb +26 -0
  18. data/lib/fundamentalista/period.rb +163 -0
  19. data/lib/fundamentalista/provider.rb +70 -0
  20. data/lib/fundamentalista/providers/edgar/facts.rb +172 -0
  21. data/lib/fundamentalista/providers/edgar/tags.rb +116 -0
  22. data/lib/fundamentalista/providers/edgar.rb +109 -0
  23. data/lib/fundamentalista/providers/fmp.rb +109 -0
  24. data/lib/fundamentalista/quote.rb +32 -0
  25. data/lib/fundamentalista/ratios.rb +231 -0
  26. data/lib/fundamentalista/scores/altman_z.rb +69 -0
  27. data/lib/fundamentalista/scores/beneish.rb +112 -0
  28. data/lib/fundamentalista/scores/piotroski.rb +111 -0
  29. data/lib/fundamentalista/serializable.rb +34 -0
  30. data/lib/fundamentalista/source.rb +27 -0
  31. data/lib/fundamentalista/statement.rb +49 -0
  32. data/lib/fundamentalista/valuation.rb +201 -0
  33. data/lib/fundamentalista/version.rb +6 -0
  34. data/lib/fundamentalista.rb +85 -0
  35. metadata +136 -0
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fundamentalista
4
+ # The market's price of a Period: multiples, yields, the Graham number,
5
+ # the Altman Z-score and a discounted cash flow intrinsic value. Built
6
+ # from a Period and a Quote, plus the next year's analyst Estimate when
7
+ # there is one, which feeds the forward multiples.
8
+ #
9
+ # valuation = company.valuation(price: 320)
10
+ # valuation.pe # => 0.4289e2
11
+ # valuation.forward_pe # => 0.3625e2, with an estimate
12
+ # valuation.fcf_yield # => 0.021e-1
13
+ # valuation.intrinsic_value(growth: 0.06, discount_rate: 0.09)
14
+ #
15
+ class Valuation
16
+ include Inspectable
17
+ include Serializable
18
+
19
+ MULTIPLES = %i[pe forward_pe pb ps ev_to_ebitda ev_to_ebit ev_to_sales peg].freeze
20
+ YIELDS = %i[earnings_yield ebit_yield fcf_yield dividend_yield shareholder_yield payout_ratio].freeze
21
+ ALL = (MULTIPLES + YIELDS + %i[market_cap enterprise_value book_value_per_share graham_number]).freeze
22
+
23
+ attr_reader :period, :quote, :estimate
24
+
25
+ def initialize(period, quote, estimate: nil)
26
+ @period = period
27
+ @quote = quote
28
+ @estimate = estimate
29
+ end
30
+
31
+ def price
32
+ quote.price
33
+ end
34
+
35
+ # Shares from the quote, else the balance sheet's count, else the
36
+ # diluted weighted average of the period.
37
+ def shares_outstanding
38
+ quote.shares_outstanding || balance.shares_outstanding || income.diluted_shares
39
+ end
40
+
41
+ def market_cap
42
+ quote.market_cap || (shares_outstanding && (price * shares_outstanding))
43
+ end
44
+
45
+ # Market capitalization plus net debt.
46
+ def enterprise_value
47
+ Decimal.sum(market_cap, balance.net_debt)
48
+ end
49
+
50
+ def pe
51
+ Decimal.ratio(price, income.eps_diluted)
52
+ end
53
+
54
+ # Price over next year's EPS: the estimate's consensus, or the +eps+
55
+ # you pass. +nil+ without either.
56
+ def forward_pe(eps: estimate&.eps)
57
+ Decimal.ratio(price, Decimal.wrap(eps))
58
+ end
59
+
60
+ def pb
61
+ Decimal.ratio(price, book_value_per_share)
62
+ end
63
+
64
+ def ps
65
+ Decimal.ratio(market_cap, income.revenue)
66
+ end
67
+
68
+ def ev_to_ebitda
69
+ Decimal.ratio(enterprise_value, income.ebitda)
70
+ end
71
+
72
+ # Enterprise value over operating income.
73
+ def ev_to_ebit
74
+ Decimal.ratio(enterprise_value, income.operating_income)
75
+ end
76
+
77
+ def ev_to_sales
78
+ Decimal.ratio(enterprise_value, income.revenue)
79
+ end
80
+
81
+ # Price to earnings over expected earnings growth in percent points.
82
+ # The growth is the one the estimate implies over this period's EPS,
83
+ # else the growth of net income against the prior period, unless
84
+ # +growth+ is given as a rate.
85
+ def peg(growth: expected_growth)
86
+ rate = Decimal.wrap(growth)
87
+ Decimal.ratio(pe, rate && (rate * 100))
88
+ end
89
+
90
+ def earnings_yield
91
+ Decimal.ratio(income.eps_diluted, price)
92
+ end
93
+
94
+ # Greenblatt's earnings yield: operating income over enterprise value.
95
+ # Rank it with Ratios#return_on_capital for the magic formula.
96
+ def ebit_yield
97
+ Decimal.ratio(income.operating_income, enterprise_value)
98
+ end
99
+
100
+ def fcf_yield
101
+ Decimal.ratio(cash_flow.free_cash_flow, market_cap)
102
+ end
103
+
104
+ def dividend_yield
105
+ Decimal.ratio(cash_flow.dividends_paid, market_cap)
106
+ end
107
+
108
+ # Dividends plus buybacks over market capitalization.
109
+ def shareholder_yield
110
+ Decimal.ratio(cash_flow.shareholder_returns, market_cap)
111
+ end
112
+
113
+ def payout_ratio
114
+ Decimal.ratio(cash_flow.dividends_paid, income.net_income)
115
+ end
116
+
117
+ def book_value_per_share
118
+ Decimal.ratio(balance.equity, shares_outstanding)
119
+ end
120
+
121
+ # Graham's ceiling for a defensive investor: the square root of 22.5
122
+ # times earnings per share times book value per share. +nil+ when
123
+ # either is negative.
124
+ def graham_number
125
+ eps = income.eps_diluted
126
+ bvps = book_value_per_share
127
+ return nil if eps.nil? || bvps.nil? || eps.negative? || bvps.negative?
128
+
129
+ (BigDecimal('22.5') * eps * bvps).sqrt(16)
130
+ end
131
+
132
+ # The Altman Z-score at this market capitalization.
133
+ def altman_z
134
+ period.altman_z(market_cap: market_cap)
135
+ end
136
+
137
+ # The DCF value per share of the period's free cash flow, net of debt.
138
+ # Takes the same keywords as DCF.
139
+ def intrinsic_value(**options)
140
+ flow = cash_flow.free_cash_flow
141
+ return nil if flow.nil? || flow <= 0 || shares_outstanding.nil?
142
+
143
+ equity = DCF.new(cash_flow: flow, **options).value - (balance.net_debt || 0)
144
+ equity / shares_outstanding
145
+ end
146
+
147
+ # How far the price sits below the intrinsic value, as a rate of that
148
+ # value; negative when the price is above it.
149
+ def margin_of_safety(**options)
150
+ value = intrinsic_value(**options)
151
+ Decimal.ratio(Decimal.subtract(value, price), value)
152
+ end
153
+
154
+ # The reverse DCF: the yearly free cash flow growth the price implies
155
+ # under the given +discount_rate+ and the other DCF keywords. +nil+
156
+ # when no growth between -50% and +100% reproduces the price.
157
+ def implied_growth(discount_rate:, **options)
158
+ return nil if intrinsic_value(growth: 0, discount_rate: discount_rate, **options).nil?
159
+
160
+ low = BigDecimal('-0.5')
161
+ high = BigDecimal('1')
162
+ return nil unless values_at?(low, discount_rate, options) < price && values_at?(high, discount_rate, options) > price
163
+
164
+ 40.times do
165
+ middle = (low + high) / 2
166
+ values_at?(middle, discount_rate, options) < price ? low = middle : high = middle
167
+ end
168
+ (low + high) / 2
169
+ end
170
+
171
+ def to_h
172
+ ALL.to_h { |name| [name, public_send(name)] }
173
+ end
174
+
175
+ def inspect_attributes # :nodoc:
176
+ { price: price, pe: pe, forward_pe: forward_pe, pb: pb, ev_to_ebitda: ev_to_ebitda, fcf_yield: fcf_yield }
177
+ end
178
+
179
+ private
180
+
181
+ def expected_growth
182
+ estimate ? estimate.eps_growth(income.eps_diluted) : period.growth(:net_income)
183
+ end
184
+
185
+ def values_at?(growth, discount_rate, options)
186
+ intrinsic_value(growth: growth, discount_rate: discount_rate, **options)
187
+ end
188
+
189
+ def income
190
+ period.income
191
+ end
192
+
193
+ def balance
194
+ period.balance
195
+ end
196
+
197
+ def cash_flow
198
+ period.cash_flow
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fundamentalista
4
+ # The version of the fundamentalista gem, as a string.
5
+ VERSION = '0.1.0'
6
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+ require 'bigdecimal/util'
5
+ require 'date'
6
+ require 'faraday'
7
+ require 'faraday/retry'
8
+ require 'json'
9
+ require 'logger'
10
+ require 'uri'
11
+ require 'zeitwerk'
12
+ require 'fundamentalista/error'
13
+
14
+ loader = Zeitwerk::Loader.for_gem
15
+ loader.inflector.inflect('dcf' => 'DCF', 'fmp' => 'FMP')
16
+ loader.setup
17
+
18
+ # Fundamental analysis of listed companies: financial statements, ratios,
19
+ # scores and valuation, from the SEC's EDGAR or Financial Modeling Prep.
20
+ #
21
+ # Fundamentalista.configure { |config| config.edgar_user_agent = "Acme research@acme.com" }
22
+ #
23
+ # company = Fundamentalista.company("AAPL")
24
+ # year = company.financials.latest
25
+ # year.ratios.roe # => 0.15e1 (BigDecimal)
26
+ # year.piotroski.score # => 7
27
+ # company.valuation(price: 320).pe
28
+ # Fundamentalista.compare("AAPL", "MSFT").rank(:roe)
29
+ #
30
+ module Fundamentalista
31
+ class << self
32
+ # Returns the Company behind +ticker+, resolved through +provider+
33
+ # (+:edgar+ or +:fmp+, defaulting to the configured provider).
34
+ def company(ticker, provider: config.default_provider)
35
+ provider(provider).company(ticker)
36
+ end
37
+
38
+ # Returns a Comparison of the companies behind +tickers+, valued at
39
+ # +prices+ where given. Tickers the provider cannot resolve are logged
40
+ # and left out.
41
+ def compare(*tickers, prices: {}, provider: config.default_provider)
42
+ source = provider(provider)
43
+ companies = tickers.flatten.filter_map do |ticker|
44
+ source.company(ticker)
45
+ rescue Error => e
46
+ logger.warn("Skipping #{ticker}: #{e.message}")
47
+ nil
48
+ end
49
+ Comparison.new(companies, prices: prices)
50
+ end
51
+
52
+ # Returns an instance of the provider registered under +slug+.
53
+ def provider(slug)
54
+ klass = providers.fetch(slug.to_sym) do
55
+ raise ConfigurationError, "Unknown provider #{slug.inspect}. Known providers: #{providers.keys.join(', ')}"
56
+ end
57
+ klass.new(config)
58
+ end
59
+
60
+ # The registered providers, keyed by slug.
61
+ def providers
62
+ @providers ||= { edgar: Providers::Edgar, fmp: Providers::FMP }
63
+ end
64
+
65
+ # The global Configuration.
66
+ def config
67
+ @config ||= Configuration.new
68
+ end
69
+
70
+ # Yields the global Configuration.
71
+ #
72
+ # Fundamentalista.configure do |config|
73
+ # config.fmp_api_key = ENV["FMP_API_KEY"]
74
+ # end
75
+ #
76
+ def configure
77
+ yield config
78
+ end
79
+
80
+ # The configured logger.
81
+ def logger
82
+ config.logger
83
+ end
84
+ end
85
+ end
metadata ADDED
@@ -0,0 +1,136 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fundamentalista
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Bruno Costanzo
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: bigdecimal
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '3.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: faraday
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: faraday-retry
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '2.0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '2.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: zeitwerk
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '2.6'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '2.6'
68
+ description: Financial statements, ratios, Piotroski and Altman scores, multiples
69
+ and discounted cash flow valuation for listed companies, from the SEC EDGAR XBRL
70
+ API or Financial Modeling Prep. BigDecimal throughout, nil instead of exceptions
71
+ on incomplete filings, one API over every provider.
72
+ email:
73
+ - dev.bcostanzo@gmail.com
74
+ executables: []
75
+ extensions: []
76
+ extra_rdoc_files: []
77
+ files:
78
+ - CHANGELOG.md
79
+ - LICENSE
80
+ - README.md
81
+ - lib/fundamentalista.rb
82
+ - lib/fundamentalista/balance_sheet.rb
83
+ - lib/fundamentalista/banking.rb
84
+ - lib/fundamentalista/cash_flow_statement.rb
85
+ - lib/fundamentalista/company.rb
86
+ - lib/fundamentalista/comparison.rb
87
+ - lib/fundamentalista/configuration.rb
88
+ - lib/fundamentalista/dcf.rb
89
+ - lib/fundamentalista/decimal.rb
90
+ - lib/fundamentalista/error.rb
91
+ - lib/fundamentalista/estimate.rb
92
+ - lib/fundamentalista/financials.rb
93
+ - lib/fundamentalista/income_statement.rb
94
+ - lib/fundamentalista/inspectable.rb
95
+ - lib/fundamentalista/period.rb
96
+ - lib/fundamentalista/provider.rb
97
+ - lib/fundamentalista/providers/edgar.rb
98
+ - lib/fundamentalista/providers/edgar/facts.rb
99
+ - lib/fundamentalista/providers/edgar/tags.rb
100
+ - lib/fundamentalista/providers/fmp.rb
101
+ - lib/fundamentalista/quote.rb
102
+ - lib/fundamentalista/ratios.rb
103
+ - lib/fundamentalista/scores/altman_z.rb
104
+ - lib/fundamentalista/scores/beneish.rb
105
+ - lib/fundamentalista/scores/piotroski.rb
106
+ - lib/fundamentalista/serializable.rb
107
+ - lib/fundamentalista/source.rb
108
+ - lib/fundamentalista/statement.rb
109
+ - lib/fundamentalista/valuation.rb
110
+ - lib/fundamentalista/version.rb
111
+ homepage: https://github.com/bruno-costanzo/fundamentalista
112
+ licenses:
113
+ - MIT
114
+ metadata:
115
+ source_code_uri: https://github.com/bruno-costanzo/fundamentalista
116
+ changelog_uri: https://github.com/bruno-costanzo/fundamentalista/blob/main/CHANGELOG.md
117
+ bug_tracker_uri: https://github.com/bruno-costanzo/fundamentalista/issues
118
+ rubygems_mfa_required: 'true'
119
+ rdoc_options: []
120
+ require_paths:
121
+ - lib
122
+ required_ruby_version: !ruby/object:Gem::Requirement
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ version: 3.1.3
127
+ required_rubygems_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ requirements: []
133
+ rubygems_version: 4.0.16
134
+ specification_version: 4
135
+ summary: Fundamental analysis of listed companies, the Ruby way.
136
+ test_files: []