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,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