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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE +21 -0
- data/README.md +297 -0
- data/lib/fundamentalista/balance_sheet.rb +44 -0
- data/lib/fundamentalista/banking.rb +32 -0
- data/lib/fundamentalista/cash_flow_statement.rb +20 -0
- data/lib/fundamentalista/company.rb +81 -0
- data/lib/fundamentalista/comparison.rb +85 -0
- data/lib/fundamentalista/configuration.rb +71 -0
- data/lib/fundamentalista/dcf.rb +73 -0
- data/lib/fundamentalista/decimal.rb +57 -0
- data/lib/fundamentalista/error.rb +23 -0
- data/lib/fundamentalista/estimate.rb +38 -0
- data/lib/fundamentalista/financials.rb +103 -0
- data/lib/fundamentalista/income_statement.rb +40 -0
- data/lib/fundamentalista/inspectable.rb +26 -0
- data/lib/fundamentalista/period.rb +163 -0
- data/lib/fundamentalista/provider.rb +70 -0
- data/lib/fundamentalista/providers/edgar/facts.rb +172 -0
- data/lib/fundamentalista/providers/edgar/tags.rb +116 -0
- data/lib/fundamentalista/providers/edgar.rb +109 -0
- data/lib/fundamentalista/providers/fmp.rb +109 -0
- data/lib/fundamentalista/quote.rb +32 -0
- data/lib/fundamentalista/ratios.rb +231 -0
- data/lib/fundamentalista/scores/altman_z.rb +69 -0
- data/lib/fundamentalista/scores/beneish.rb +112 -0
- data/lib/fundamentalista/scores/piotroski.rb +111 -0
- data/lib/fundamentalista/serializable.rb +34 -0
- data/lib/fundamentalista/source.rb +27 -0
- data/lib/fundamentalista/statement.rb +49 -0
- data/lib/fundamentalista/valuation.rb +201 -0
- data/lib/fundamentalista/version.rb +6 -0
- data/lib/fundamentalista.rb +85 -0
- metadata +136 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fundamentalista
|
|
4
|
+
# Global settings, reached through Fundamentalista.configure.
|
|
5
|
+
#
|
|
6
|
+
# Fundamentalista.configure do |config|
|
|
7
|
+
# config.edgar_user_agent = "Acme Research research@acme.com"
|
|
8
|
+
# config.fmp_api_key = ENV["FMP_API_KEY"]
|
|
9
|
+
# config.default_provider = :fmp
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
class Configuration
|
|
13
|
+
class << self
|
|
14
|
+
def option(key, default = nil) # :nodoc:
|
|
15
|
+
attr_reader key
|
|
16
|
+
|
|
17
|
+
define_method(:"#{key}=") do |value|
|
|
18
|
+
value = nil if value.is_a?(String) && value.strip.empty?
|
|
19
|
+
instance_variable_set(:"@#{key}", value)
|
|
20
|
+
end
|
|
21
|
+
defaults[key] = default
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def defaults # :nodoc:
|
|
25
|
+
@defaults ||= {}
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The User-Agent the SEC requires on every EDGAR request: an app name
|
|
30
|
+
# and a contact email.
|
|
31
|
+
option :edgar_user_agent
|
|
32
|
+
|
|
33
|
+
# Requests per second sent to EDGAR; the SEC allows ten.
|
|
34
|
+
option :edgar_requests_per_second, 8
|
|
35
|
+
|
|
36
|
+
# The Financial Modeling Prep API key.
|
|
37
|
+
option :fmp_api_key
|
|
38
|
+
|
|
39
|
+
# The provider Fundamentalista.company uses when none is given.
|
|
40
|
+
option :default_provider, :edgar
|
|
41
|
+
|
|
42
|
+
# Seconds to wait for a provider response.
|
|
43
|
+
option :request_timeout, 30
|
|
44
|
+
|
|
45
|
+
# Retries on transient provider failures.
|
|
46
|
+
option :max_retries, 2
|
|
47
|
+
|
|
48
|
+
# A store responding to +fetch(key) { value }+, such as an
|
|
49
|
+
# ActiveSupport::Cache store, that keeps provider responses. EDGAR
|
|
50
|
+
# documents run to megabytes, so set one in any long-lived process.
|
|
51
|
+
option :cache
|
|
52
|
+
|
|
53
|
+
# The logger.
|
|
54
|
+
option :logger
|
|
55
|
+
|
|
56
|
+
def initialize
|
|
57
|
+
self.class.defaults.each { |key, value| instance_variable_set(:"@#{key}", value) }
|
|
58
|
+
@logger ||= Logger.new($stdout, level: Logger::INFO)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Shows every setting, with keys redacted.
|
|
62
|
+
def inspect
|
|
63
|
+
settings = self.class.defaults.keys.map do |key|
|
|
64
|
+
value = public_send(key)
|
|
65
|
+
value = '[REDACTED]' if key.end_with?('_key') && value
|
|
66
|
+
"#{key}: #{value.inspect}"
|
|
67
|
+
end
|
|
68
|
+
"#<#{self.class.name} #{settings.join(', ')}>"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fundamentalista
|
|
4
|
+
# A discounted cash flow: a starting cash flow grown at +growth+ for
|
|
5
|
+
# +years+, then a terminal value at +terminal_growth+, all discounted at
|
|
6
|
+
# +discount_rate+. With +fade+ the growth declines evenly from +growth+
|
|
7
|
+
# in the first year to +terminal_growth+ in the last, instead of
|
|
8
|
+
# dropping at once. Rates are decimals, so 0.08 means 8%.
|
|
9
|
+
#
|
|
10
|
+
# dcf = Fundamentalista::DCF.new(cash_flow: 98_767_000_000, growth: 0.06, discount_rate: 0.09)
|
|
11
|
+
# dcf.value # present value of the projected flows plus the terminal value
|
|
12
|
+
# dcf.projection # the flows, year by year
|
|
13
|
+
# Fundamentalista::DCF.new(cash_flow: 100, growth: 0.15, discount_rate: 0.1, fade: true).growth_path
|
|
14
|
+
#
|
|
15
|
+
class DCF
|
|
16
|
+
include Inspectable
|
|
17
|
+
|
|
18
|
+
attr_reader :cash_flow, :growth, :discount_rate, :terminal_growth, :years
|
|
19
|
+
|
|
20
|
+
def initialize(cash_flow:, growth:, discount_rate:, terminal_growth: BigDecimal('0.025'), years: 10, fade: false)
|
|
21
|
+
@cash_flow = Decimal.wrap(cash_flow)
|
|
22
|
+
@growth = Decimal.wrap(growth)
|
|
23
|
+
@discount_rate = Decimal.wrap(discount_rate)
|
|
24
|
+
@terminal_growth = Decimal.wrap(terminal_growth)
|
|
25
|
+
@years = years
|
|
26
|
+
@fade = fade
|
|
27
|
+
raise ArgumentError, 'discount_rate must exceed terminal_growth' if @discount_rate <= @terminal_growth
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Whether growth fades towards the terminal rate over the projection.
|
|
31
|
+
def fade?
|
|
32
|
+
@fade
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The growth rate applied in each year, 1 to +years+.
|
|
36
|
+
def growth_path
|
|
37
|
+
return Array.new(years, growth) unless fade? && years > 1
|
|
38
|
+
|
|
39
|
+
step = (growth - terminal_growth) / (years - 1)
|
|
40
|
+
(0...years).map { |index| growth - (step * index) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The projected cash flow of each year, 1 to +years+.
|
|
44
|
+
def projection
|
|
45
|
+
growth_path.each_with_object([]) { |rate, flows| flows << ((flows.last || cash_flow) * (1 + rate)) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The present value of the projected cash flows.
|
|
49
|
+
def present_value
|
|
50
|
+
projection.each_with_index.sum(BigDecimal('0')) { |flow, index| discount(flow, index + 1) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The terminal value at the end of the projection, undiscounted.
|
|
54
|
+
def terminal_value
|
|
55
|
+
projection.last * (1 + terminal_growth) / (discount_rate - terminal_growth)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The present value of the projection plus the discounted terminal value.
|
|
59
|
+
def value
|
|
60
|
+
present_value + discount(terminal_value, years)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def inspect_attributes # :nodoc:
|
|
64
|
+
{ cash_flow: cash_flow, growth: growth, discount_rate: discount_rate, years: years, fade: (true if fade?), value: value }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def discount(amount, year)
|
|
70
|
+
amount / ((1 + discount_rate)**year)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fundamentalista
|
|
4
|
+
# Arithmetic over values that may be missing. Every statement figure is a
|
|
5
|
+
# BigDecimal or +nil+, and every derived number stays +nil+ when an input
|
|
6
|
+
# is missing, so a ratio never raises on an incomplete filing.
|
|
7
|
+
module Decimal
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Returns +value+ as a BigDecimal, or +nil+ for +nil+ and blank strings.
|
|
11
|
+
def wrap(value)
|
|
12
|
+
return value if value.is_a?(BigDecimal)
|
|
13
|
+
return nil if value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
14
|
+
|
|
15
|
+
BigDecimal(value.to_s)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Returns +numerator+ divided by +denominator+, or +nil+ when either is
|
|
19
|
+
# missing or the denominator is zero.
|
|
20
|
+
def ratio(numerator, denominator)
|
|
21
|
+
return nil if numerator.nil? || denominator.nil? || denominator.zero?
|
|
22
|
+
|
|
23
|
+
numerator / denominator
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns the sum of +values+, or +nil+ when any is missing.
|
|
27
|
+
def sum(*values)
|
|
28
|
+
return nil if values.any?(&:nil?)
|
|
29
|
+
|
|
30
|
+
values.sum(BigDecimal('0'))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns +minuend+ minus +subtrahend+, or +nil+ when either is missing.
|
|
34
|
+
def subtract(minuend, subtrahend)
|
|
35
|
+
return nil if minuend.nil? || subtrahend.nil?
|
|
36
|
+
|
|
37
|
+
minuend - subtrahend
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns the mean of +values+, ignoring missing ones, or +nil+ when all
|
|
41
|
+
# are missing.
|
|
42
|
+
def average(*values)
|
|
43
|
+
present = values.compact
|
|
44
|
+
return nil if present.empty?
|
|
45
|
+
|
|
46
|
+
present.sum(BigDecimal('0')) / present.length
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns the compound annual growth rate from +first+ to +last+ over
|
|
50
|
+
# +years+, or +nil+ when it cannot be computed.
|
|
51
|
+
def cagr(first, last, years)
|
|
52
|
+
return nil if first.nil? || last.nil? || years.nil? || years <= 0 || first <= 0 || last <= 0
|
|
53
|
+
|
|
54
|
+
BigDecimal((last / first).to_f**(1.0 / years), 12) - 1
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fundamentalista
|
|
4
|
+
# Base class for errors raised while talking to a data provider. Carries
|
|
5
|
+
# the HTTP response when there is one.
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
attr_reader :response
|
|
8
|
+
|
|
9
|
+
def initialize(message = nil, response: nil)
|
|
10
|
+
@response = response
|
|
11
|
+
super(message || response&.body.to_s)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class ConfigurationError < StandardError; end
|
|
16
|
+
class CompanyNotFoundError < Error; end
|
|
17
|
+
class UnauthorizedError < Error; end
|
|
18
|
+
class RateLimitError < Error; end
|
|
19
|
+
class ServerError < Error; end
|
|
20
|
+
class UnsupportedPeriodError < StandardError; end
|
|
21
|
+
class NoFinancialsError < StandardError; end
|
|
22
|
+
class QuoteUnavailableError < StandardError; end
|
|
23
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fundamentalista
|
|
4
|
+
# The analyst consensus for a future fiscal year: average, low and high
|
|
5
|
+
# of the estimated EPS, plus revenue, EBITDA and net income averages and
|
|
6
|
+
# the number of analysts behind the EPS figure.
|
|
7
|
+
class Estimate
|
|
8
|
+
include Inspectable
|
|
9
|
+
include Serializable
|
|
10
|
+
|
|
11
|
+
attr_reader :fiscal_year_end, :eps, :eps_low, :eps_high, :revenue, :ebitda, :net_income, :analysts
|
|
12
|
+
|
|
13
|
+
def initialize(fiscal_year_end:, eps:, eps_low: nil, eps_high: nil, revenue: nil, ebitda: nil, net_income: nil, analysts: nil)
|
|
14
|
+
@fiscal_year_end = fiscal_year_end
|
|
15
|
+
@eps = Decimal.wrap(eps)
|
|
16
|
+
@eps_low = Decimal.wrap(eps_low)
|
|
17
|
+
@eps_high = Decimal.wrap(eps_high)
|
|
18
|
+
@revenue = Decimal.wrap(revenue)
|
|
19
|
+
@ebitda = Decimal.wrap(ebitda)
|
|
20
|
+
@net_income = Decimal.wrap(net_income)
|
|
21
|
+
@analysts = analysts
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The fiscal year the estimate covers.
|
|
25
|
+
def fiscal_year
|
|
26
|
+
fiscal_year_end.year
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The growth the consensus EPS implies over +eps+, as a rate.
|
|
30
|
+
def eps_growth(from)
|
|
31
|
+
Decimal.cagr(Decimal.wrap(from), eps, 1)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def inspect_attributes # :nodoc:
|
|
35
|
+
{ fiscal_year: fiscal_year, eps: eps, revenue: revenue, analysts: analysts }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fundamentalista
|
|
4
|
+
# The reporting history of a company: an ordered collection of Periods,
|
|
5
|
+
# newest first, each linked to the one before it.
|
|
6
|
+
#
|
|
7
|
+
# financials = company.financials(limit: 5)
|
|
8
|
+
# financials.latest.ratios.net_margin
|
|
9
|
+
# financials.growth(:revenue) # CAGR over the whole history
|
|
10
|
+
# financials.series(:free_cash_flow) # {2025 => ..., 2024 => ...}
|
|
11
|
+
#
|
|
12
|
+
class Financials
|
|
13
|
+
include Enumerable
|
|
14
|
+
include Inspectable
|
|
15
|
+
include Serializable
|
|
16
|
+
|
|
17
|
+
def initialize(periods)
|
|
18
|
+
@periods = periods.sort_by(&:ended_on).reverse
|
|
19
|
+
@periods.each_cons(2) { |period, prior| period.prior = prior }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def each(&)
|
|
23
|
+
@periods.each(&)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The most recent Period.
|
|
27
|
+
def latest
|
|
28
|
+
@periods.first
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The oldest Period.
|
|
32
|
+
def oldest
|
|
33
|
+
@periods.last
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The number of periods.
|
|
37
|
+
def size
|
|
38
|
+
@periods.size
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns the Period of +fiscal_year+, and of +quarter+ within it for
|
|
42
|
+
# quarterly periods, or +nil+.
|
|
43
|
+
def year(fiscal_year, quarter = nil)
|
|
44
|
+
@periods.find { |period| period.fiscal_year == fiscal_year && period.quarter == quarter }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns every period as a Hash, newest first.
|
|
48
|
+
def to_h
|
|
49
|
+
{ periods: @periods.map(&:to_h) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns +metric+ for every period keyed by fiscal year, or by
|
|
53
|
+
# [fiscal year, quarter] for quarterly periods, newest first.
|
|
54
|
+
def series(metric)
|
|
55
|
+
@periods.to_h do |period|
|
|
56
|
+
[period.quarter ? [period.fiscal_year, period.quarter] : period.fiscal_year, period.metric(metric)]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns the trailing twelve months as one Period: the last four
|
|
61
|
+
# quarters' flows added up, the latest balance sheet, and the four
|
|
62
|
+
# quarters before as its prior period when there are eight. Needs
|
|
63
|
+
# quarterly periods.
|
|
64
|
+
def ttm
|
|
65
|
+
raise UnsupportedPeriodError, 'No quarterly periods on file; IFRS filers report annually on EDGAR' if size.zero?
|
|
66
|
+
raise UnsupportedPeriodError, 'The trailing twelve months need quarterly periods' unless latest.quarterly?
|
|
67
|
+
raise UnsupportedPeriodError, 'The trailing twelve months need four quarters' if size < 4
|
|
68
|
+
|
|
69
|
+
trailing(@periods.first(4), prior: size >= 8 ? trailing(@periods[4, 4]) : nil)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns the compound annual growth rate of +metric+ from the period
|
|
73
|
+
# +years+ back to the latest one, or over the whole history by default.
|
|
74
|
+
# Quarterly periods count four to a year.
|
|
75
|
+
def growth(metric, years: nil)
|
|
76
|
+
span = years ? periods_per_year * years : size - 1
|
|
77
|
+
base = @periods[span]
|
|
78
|
+
return nil if base.nil? || span.zero?
|
|
79
|
+
|
|
80
|
+
Decimal.cagr(base.metric(metric), latest.metric(metric), span.to_f / periods_per_year)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def inspect_attributes # :nodoc:
|
|
84
|
+
{ periods: size, latest: latest&.fiscal_year, oldest: oldest&.fiscal_year }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def trailing(quarters, prior: nil)
|
|
90
|
+
Period.new(
|
|
91
|
+
fiscal_year: quarters.first.fiscal_year, ended_on: quarters.first.ended_on, type: :ttm, prior: prior,
|
|
92
|
+
currency: quarters.first.currency, balance: quarters.first.balance,
|
|
93
|
+
income: IncomeStatement.sum(quarters.map(&:income)),
|
|
94
|
+
cash_flow: CashFlowStatement.sum(quarters.map(&:cash_flow)),
|
|
95
|
+
banking: Banking.sum(quarters.map(&:banking))
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def periods_per_year
|
|
100
|
+
latest&.quarterly? ? 4 : 1
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fundamentalista
|
|
4
|
+
# One period of the income statement. Figures are in the reporting
|
|
5
|
+
# currency; +eps_diluted+ is per share, +diluted_shares+ a share count
|
|
6
|
+
# and +sga+ the selling, general and administrative expense.
|
|
7
|
+
class IncomeStatement < Statement
|
|
8
|
+
field :revenue, :cost_of_revenue, :gross_profit, :sga, :operating_income, :interest_expense,
|
|
9
|
+
:income_before_tax, :income_tax, :net_income, :depreciation_amortization, :ebitda,
|
|
10
|
+
:eps_diluted, :diluted_shares
|
|
11
|
+
|
|
12
|
+
# Gross profit, derived from revenue and cost of revenue when not reported.
|
|
13
|
+
def gross_profit
|
|
14
|
+
@gross_profit || Decimal.subtract(revenue, cost_of_revenue)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# EBITDA, derived from operating income and depreciation when not reported.
|
|
18
|
+
def ebitda
|
|
19
|
+
@ebitda || Decimal.sum(operating_income, depreciation_amortization)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Diluted earnings per share, derived from net income and diluted shares
|
|
23
|
+
# when not reported.
|
|
24
|
+
def eps_diluted
|
|
25
|
+
@eps_diluted || Decimal.ratio(net_income, diluted_shares)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Adds up +statements+, averaging the diluted share count instead.
|
|
29
|
+
def self.sum(statements)
|
|
30
|
+
summed = super
|
|
31
|
+
shares = Decimal.average(*statements.map(&:diluted_shares))
|
|
32
|
+
new(**summed.to_h, eps_diluted: summed.instance_variable_get(:@eps_diluted), diluted_shares: shares)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The effective tax rate, income tax over income before tax.
|
|
36
|
+
def tax_rate
|
|
37
|
+
Decimal.ratio(income_tax, income_before_tax)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fundamentalista
|
|
4
|
+
module Inspectable # :nodoc:
|
|
5
|
+
def inspect
|
|
6
|
+
attributes = inspect_attributes.compact.map { |name, value| "#{name}: #{format_for_inspect(value)}" }
|
|
7
|
+
return "#<#{self.class.name}>" if attributes.empty?
|
|
8
|
+
|
|
9
|
+
"#<#{self.class.name} #{attributes.join(', ')}>"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def pretty_print(printer)
|
|
13
|
+
printer.text(inspect)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def format_for_inspect(value)
|
|
19
|
+
case value
|
|
20
|
+
when BigDecimal then value.round(4).to_s('F')
|
|
21
|
+
when Date then value.iso8601
|
|
22
|
+
else value.inspect
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fundamentalista
|
|
4
|
+
# One reporting period: a fiscal year, a quarter, or the trailing twelve
|
|
5
|
+
# months, with its three statements. Periods know the period before
|
|
6
|
+
# them, which is what period-over-period metrics such as ROE on average
|
|
7
|
+
# equity, growth and the Piotroski score are built on.
|
|
8
|
+
class Period
|
|
9
|
+
include Inspectable
|
|
10
|
+
include Serializable
|
|
11
|
+
|
|
12
|
+
# The fiscal year the period belongs to, as an Integer.
|
|
13
|
+
attr_reader :fiscal_year
|
|
14
|
+
|
|
15
|
+
# +:annual+, +:quarterly+ or +:ttm+.
|
|
16
|
+
attr_reader :type
|
|
17
|
+
|
|
18
|
+
# The quarter within the fiscal year, 1 to 4, for quarterly periods.
|
|
19
|
+
attr_reader :quarter
|
|
20
|
+
|
|
21
|
+
# The last day of the period, as a Date.
|
|
22
|
+
attr_reader :ended_on
|
|
23
|
+
|
|
24
|
+
# The IncomeStatement, BalanceSheet and CashFlowStatement.
|
|
25
|
+
attr_reader :income, :balance, :cash_flow
|
|
26
|
+
|
|
27
|
+
# The Banking lines, empty for companies that are not banks.
|
|
28
|
+
attr_reader :banking
|
|
29
|
+
|
|
30
|
+
# Where each line item came from, keyed by item, as Source objects.
|
|
31
|
+
# Empty when the provider does not say.
|
|
32
|
+
attr_reader :sources
|
|
33
|
+
|
|
34
|
+
# The ISO code of the reporting currency.
|
|
35
|
+
attr_reader :currency
|
|
36
|
+
|
|
37
|
+
# The period before this one, or +nil+.
|
|
38
|
+
attr_accessor :prior
|
|
39
|
+
|
|
40
|
+
def initialize(fiscal_year:, ended_on:, income:, balance:, cash_flow:, banking: Banking.new, sources: {},
|
|
41
|
+
type: :annual, quarter: nil, currency: 'USD', prior: nil)
|
|
42
|
+
@fiscal_year = fiscal_year
|
|
43
|
+
@ended_on = ended_on
|
|
44
|
+
@income = income
|
|
45
|
+
@balance = balance
|
|
46
|
+
@cash_flow = cash_flow
|
|
47
|
+
@banking = banking
|
|
48
|
+
@sources = sources
|
|
49
|
+
@type = type
|
|
50
|
+
@quarter = quarter
|
|
51
|
+
@currency = currency
|
|
52
|
+
@prior = prior
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def annual?
|
|
56
|
+
type == :annual
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def quarterly?
|
|
60
|
+
type == :quarterly
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def ttm?
|
|
64
|
+
type == :ttm
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Whether the company is a bank, judged by net interest income.
|
|
68
|
+
def bank?
|
|
69
|
+
banking.bank?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns the Source of the line item +name+, or +nil+.
|
|
73
|
+
def source(name)
|
|
74
|
+
sources[name]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The period one year earlier: the prior period for annual ones, four
|
|
78
|
+
# periods back for quarters, or +nil+.
|
|
79
|
+
def year_ago
|
|
80
|
+
quarterly? ? 4.times.reduce(self) { |period, _| period&.prior } : prior
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns the growth of +metric+ against the same period a year
|
|
84
|
+
# earlier as a rate, or +nil+ without one or a positive base.
|
|
85
|
+
def yoy(metric)
|
|
86
|
+
earlier = year_ago
|
|
87
|
+
earlier && Decimal.cagr(earlier.metric(metric), self.metric(metric), 1)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns the period and its statements as a Hash.
|
|
91
|
+
def to_h
|
|
92
|
+
{ 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 }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The days the period spans, for turning turnover into days.
|
|
97
|
+
def days
|
|
98
|
+
quarterly? ? BigDecimal('91.25') : BigDecimal('365')
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Buffett's owner earnings: net income plus depreciation and
|
|
102
|
+
# amortization minus capital expenditure.
|
|
103
|
+
def owner_earnings
|
|
104
|
+
Decimal.subtract(Decimal.sum(income.net_income, income.depreciation_amortization), cash_flow.capital_expenditure)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Returns the Beneish M-score of this period.
|
|
108
|
+
def beneish
|
|
109
|
+
Scores::Beneish.new(self)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Returns the Ratios of this period.
|
|
113
|
+
def ratios
|
|
114
|
+
Ratios.new(self)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Returns the Piotroski F-score of this period.
|
|
118
|
+
def piotroski
|
|
119
|
+
Scores::Piotroski.new(self)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Returns the Altman Z-score of this period for +market_cap+.
|
|
123
|
+
def altman_z(market_cap:)
|
|
124
|
+
Scores::AltmanZ.new(self, market_cap: market_cap)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Returns the line item +name+ from whichever statement reports it, or
|
|
128
|
+
# +nil+. Derived items such as +free_cash_flow+ count, so does
|
|
129
|
+
# +owner_earnings+, and any of them followed by +_per_share+ is divided
|
|
130
|
+
# by the shares outstanding.
|
|
131
|
+
#
|
|
132
|
+
# period.metric(:revenue) # => 0.416161e12
|
|
133
|
+
# period.metric(:free_cash_flow_per_share) # => 0.66854e1
|
|
134
|
+
#
|
|
135
|
+
def metric(name)
|
|
136
|
+
return owner_earnings if name == :owner_earnings
|
|
137
|
+
return per_share(name.to_s.delete_suffix('_per_share').to_sym) if name.end_with?('_per_share')
|
|
138
|
+
|
|
139
|
+
statement = [income, balance, cash_flow, banking].find { |candidate| candidate.respond_to?(name) }
|
|
140
|
+
raise ArgumentError, "Unknown metric #{name.inspect}" unless statement
|
|
141
|
+
|
|
142
|
+
statement.public_send(name)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Returns +name+ divided by the shares outstanding at the end of the
|
|
146
|
+
# period, or by the diluted weighted average when the count is missing.
|
|
147
|
+
def per_share(name)
|
|
148
|
+
Decimal.ratio(metric(name), balance.shares_outstanding || income.diluted_shares)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Returns the growth of +metric+ against the prior period as a rate, or
|
|
152
|
+
# +nil+ without a prior period or a positive base.
|
|
153
|
+
def growth(metric)
|
|
154
|
+
return nil unless prior
|
|
155
|
+
|
|
156
|
+
Decimal.cagr(prior.metric(metric), self.metric(metric), 1)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def inspect_attributes # :nodoc:
|
|
160
|
+
{ fiscal_year: fiscal_year, quarter: quarter, type: type, ended_on: ended_on }
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fundamentalista
|
|
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.
|
|
7
|
+
class Provider
|
|
8
|
+
class << self
|
|
9
|
+
# The symbol the provider is registered under.
|
|
10
|
+
def slug
|
|
11
|
+
name.split('::').last.downcase.to_sym
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :config
|
|
16
|
+
|
|
17
|
+
def initialize(config)
|
|
18
|
+
@config = config
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def company(ticker)
|
|
22
|
+
raise NotImplementedError
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def financials(company, period:, limit:)
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def quote(company)
|
|
30
|
+
raise NotImplementedError
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def estimates(company)
|
|
34
|
+
raise NotImplementedError
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def get(url, params = {}, headers: {})
|
|
40
|
+
key = "fundamentalista:#{url}?#{URI.encode_www_form(params.sort)}"
|
|
41
|
+
config.cache ? config.cache.fetch(key) { fetch_json(url, params, headers) } : fetch_json(url, params, headers)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fetch_json(url, params, headers)
|
|
45
|
+
response = connection(headers).get(url, params)
|
|
46
|
+
check(response)
|
|
47
|
+
response.body
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def connection(headers)
|
|
51
|
+
@connections ||= {}
|
|
52
|
+
@connections[headers] ||= Faraday.new(headers: headers, request: { timeout: config.request_timeout }) do |faraday|
|
|
53
|
+
faraday.request :retry, max: config.max_retries, interval: 0.5, backoff_factor: 2,
|
|
54
|
+
retry_statuses: [429, 500, 502, 503, 504]
|
|
55
|
+
faraday.response :json, content_type: /\bjson$/
|
|
56
|
+
faraday.adapter Faraday.default_adapter
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def check(response)
|
|
61
|
+
case response.status
|
|
62
|
+
when 200..299 then response
|
|
63
|
+
when 401, 403 then raise UnauthorizedError.new("#{self.class.slug} rejected the credentials", response: response)
|
|
64
|
+
when 404 then raise CompanyNotFoundError.new('not found', response: response)
|
|
65
|
+
when 429 then raise RateLimitError.new("#{self.class.slug} rate limit reached", response: response)
|
|
66
|
+
else raise ServerError.new("#{self.class.slug} answered #{response.status}", response: response)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|