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,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fundamentalista
4
+ module Providers
5
+ class Edgar
6
+ # Reads a companyfacts document. Every concept carries every value
7
+ # ever filed, including restatements, so a line item is looked up by
8
+ # the period it describes and the most recently filed value wins,
9
+ # while the fiscal year label comes from the original filing.
10
+ #
11
+ # Quarterly flows are not always reported as such: cash flow
12
+ # statements in 10-Q filings are year to date, and the fourth quarter
13
+ # only exists as the annual figure. A quarter is read directly when a
14
+ # three month value exists, and derived otherwise as the difference
15
+ # between two year to date values that share a fiscal year start.
16
+ class Facts # :nodoc:
17
+ ANNUAL = (350..380)
18
+ QUARTER = (80..100)
19
+ ANNUAL_FORMS = %w[10-K 20-F 40-F].freeze
20
+ QUARTERLY_FORMS = %w[10-K 10-Q].freeze
21
+ QUARTERS = { 'Q1' => 1, 'Q2' => 2, 'Q3' => 3, 'FY' => 4 }.freeze
22
+ TAXONOMIES = %w[us-gaap ifrs-full dei].freeze
23
+
24
+ def initialize(document)
25
+ facts = document.fetch('facts', {})
26
+ @facts = TAXONOMIES.map { |taxonomy| facts.fetch(taxonomy, {}) }
27
+ end
28
+
29
+ # The ISO code of the currency revenue is reported in.
30
+ def currency
31
+ Tags::INCOME[:revenue].each do |concept|
32
+ unit = units(concept).keys.find { |candidate| candidate.match?(/\A[A-Z]{3}\z/) }
33
+ return unit if unit
34
+ end
35
+ 'USD'
36
+ end
37
+
38
+ # The fiscal years on file, newest first, as [year, ended_on] pairs.
39
+ def fiscal_years
40
+ balance_dates(ANNUAL_FORMS).map { |original, ended_on| [original['fy'], ended_on] }
41
+ end
42
+
43
+ # The quarters on file, newest first, as [year, quarter, ended_on]
44
+ # triples. The fourth quarter is the fiscal year end.
45
+ def quarters
46
+ balance_dates(QUARTERLY_FORMS).map { |original, ended_on| [original['fy'], QUARTERS.fetch(original['fp']), ended_on] }
47
+ end
48
+
49
+ # The annual line items of +tags+ for the fiscal year +year+ ending
50
+ # on +ended_on+, as [value, Source] pairs keyed by item. Cover page
51
+ # facts fill in what the statements lack.
52
+ def annual(tags, ended_on, year = nil)
53
+ tags.to_h do |item, alternatives|
54
+ found = first_reported(alternatives) do |concept|
55
+ instant?(item) ? instant(concept, ended_on, ANNUAL_FORMS) : yearly(concept, ended_on)
56
+ end
57
+ [item, found || cover(item, year)]
58
+ end
59
+ end
60
+
61
+ # The quarterly line items of +tags+ for the quarter ending on
62
+ # +ended_on+, the one before it having ended on +previous+, as
63
+ # [value, Source] pairs keyed by item.
64
+ def quarterly(tags, ended_on, previous)
65
+ tags.to_h do |item, alternatives|
66
+ [item, first_reported(alternatives) do |concept|
67
+ instant?(item) ? instant(concept, ended_on) : three_months(concept, ended_on, previous)
68
+ end]
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def cover(item, year)
75
+ concept = Tags::COVER[item]
76
+ return nil unless concept && year
77
+
78
+ latest(concept, entries(concept).select { |entry| entry['fy'] == year && ANNUAL_FORMS.include?(entry['form']) })
79
+ end
80
+
81
+ def balance_dates(forms)
82
+ entries('Assets').select { |entry| balance_at?(entry, forms) }
83
+ .group_by { |entry| entry['end'] }
84
+ .map do |ended_on, group|
85
+ [group.min_by do |entry|
86
+ [entry['fy'], entry['filed']]
87
+ end, Date.parse(ended_on)]
88
+ end
89
+ .sort_by(&:last).reverse
90
+ end
91
+
92
+ def balance_at?(entry, forms)
93
+ entry['start'].nil? && entry['fp'] && forms.include?(entry['form'])
94
+ end
95
+
96
+ def first_reported(alternatives, &block)
97
+ alternatives.each do |alternative|
98
+ found = Array(alternative).filter_map(&block)
99
+ return combine(found) if found.any?
100
+ end
101
+ nil
102
+ end
103
+
104
+ def combine(found)
105
+ return found.first if found.one?
106
+
107
+ source = Source.new(concept: found.map { |_, source| source.concept }.join('+'), form: found.first.last.form,
108
+ filed: found.map { |_, source| source.filed }.max, derivation: :summed)
109
+ [found.sum { |value, _| value }, source]
110
+ end
111
+
112
+ def instant?(item)
113
+ Tags::INSTANT.include?(item)
114
+ end
115
+
116
+ def instant(concept, ended_on, forms = nil)
117
+ latest(concept, ending(concept, ended_on).select do |entry|
118
+ entry['start'].nil? && (forms.nil? || forms.include?(entry['form']))
119
+ end)
120
+ end
121
+
122
+ def yearly(concept, ended_on)
123
+ latest(concept, ending(concept, ended_on).select do |entry|
124
+ ANNUAL_FORMS.include?(entry['form']) && ANNUAL.cover?(duration_of(entry))
125
+ end)
126
+ end
127
+
128
+ def three_months(concept, ended_on, previous)
129
+ spans = ending(concept, ended_on).select { |entry| entry['start'] }
130
+ latest(concept, spans.select do |entry|
131
+ QUARTER.cover?(duration_of(entry))
132
+ end) || derived_quarter(concept, spans, previous)
133
+ end
134
+
135
+ def derived_quarter(concept, spans, previous)
136
+ year_to_date = spans.max_by { |entry| [duration_of(entry), entry['filed']] }
137
+ return nil unless year_to_date && previous
138
+
139
+ earlier = latest(concept, ending(concept, previous).select { |entry| entry['start'] == year_to_date['start'] })
140
+ earlier && [year_to_date['val'] - earlier.first, source_of(concept, year_to_date, :derived)]
141
+ end
142
+
143
+ def latest(concept, candidates)
144
+ entry = candidates.max_by { |candidate| candidate['filed'] }
145
+ entry && [entry['val'], source_of(concept, entry)]
146
+ end
147
+
148
+ def source_of(concept, entry, derivation = :reported)
149
+ Source.new(concept: concept, form: entry['form'], filed: Date.parse(entry['filed']), derivation: derivation)
150
+ end
151
+
152
+ def ending(concept, ended_on)
153
+ entries(concept).select { |entry| entry['end'] == ended_on.iso8601 }
154
+ end
155
+
156
+ def duration_of(entry)
157
+ return -1 unless entry['start']
158
+
159
+ (Date.parse(entry['end']) - Date.parse(entry['start'])).to_i
160
+ end
161
+
162
+ def entries(concept)
163
+ units(concept).values.flatten
164
+ end
165
+
166
+ def units(concept)
167
+ @facts.flat_map { |taxonomy| taxonomy.dig(concept, 'units').to_a }.to_h
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fundamentalista
4
+ module Providers
5
+ class Edgar
6
+ # The XBRL concepts behind each line item, in order of preference,
7
+ # from the US GAAP taxonomy of 10-K filers and the IFRS taxonomy of
8
+ # 20-F filers. Companies tag the same idea differently, so each item
9
+ # lists the alternatives that express it and the first one reported
10
+ # wins. An alternative given as an array is the sum of the concepts
11
+ # it names, for items companies split, such as current debt and
12
+ # commercial paper. Duration concepts describe a period, instant
13
+ # concepts a date.
14
+ module Tags
15
+ INCOME = {
16
+ revenue: %w[RevenueFromContractWithCustomerExcludingAssessedTax Revenues SalesRevenueNet
17
+ RevenueFromContractWithCustomerIncludingAssessedTax RevenuesNetOfInterestExpense Revenue
18
+ RevenueFromContractsWithCustomers],
19
+ cost_of_revenue: %w[CostOfRevenue CostOfGoodsAndServicesSold CostOfGoodsSold CostOfSales
20
+ CostOfGoodsAndServiceExcludingDepreciationDepletionAndAmortization],
21
+ gross_profit: %w[GrossProfit],
22
+ sga: ['SellingGeneralAndAdministrativeExpense', %w[GeneralAndAdministrativeExpense SellingAndMarketingExpense]],
23
+ operating_income: %w[OperatingIncomeLoss ProfitLossFromOperatingActivities],
24
+ interest_expense: %w[InterestExpense InterestExpenseNonoperating InterestExpenseDebt FinanceCosts
25
+ InterestExpenseOperating InterestAndDebtExpense InterestExpenseLongTermDebt
26
+ InterestExpenseBorrowings InterestPaidNet],
27
+ income_before_tax: %w[
28
+ IncomeLossFromContinuingOperationsBeforeIncomeTaxesExtraordinaryItemsNoncontrollingInterest
29
+ IncomeLossFromContinuingOperationsBeforeIncomeTaxesMinorityInterestAndIncomeLossFromEquityMethodInvestments
30
+ ProfitLossBeforeTax
31
+ ],
32
+ income_tax: %w[IncomeTaxExpenseBenefit IncomeTaxExpenseContinuingOperations],
33
+ net_income: %w[NetIncomeLoss ProfitLossAttributableToOwnersOfParent ProfitLoss],
34
+ depreciation_amortization: ['DepreciationDepletionAndAmortization', 'DepreciationAndAmortization',
35
+ 'DepreciationAmortizationAndAccretionNet', 'DepreciationAmortizationAndOther',
36
+ 'DepreciationAndAmortisationExpense',
37
+ %w[DepreciationPropertyPlantAndEquipment AmortisationIntangibleAssetsOtherThanGoodwill],
38
+ %w[Depreciation AmortizationOfIntangibleAssets], 'Depreciation'],
39
+ eps_diluted: %w[EarningsPerShareDiluted DilutedEarningsLossPerShare],
40
+ diluted_shares: %w[WeightedAverageNumberOfDilutedSharesOutstanding AdjustedWeightedAverageShares]
41
+ }.freeze
42
+
43
+ BALANCE = {
44
+ cash: %w[CashAndCashEquivalentsAtCarryingValue CashCashEquivalentsRestrictedCashAndRestrictedCashEquivalents
45
+ CashAndCashEquivalents],
46
+ short_term_investments: %w[ShortTermInvestments MarketableSecuritiesCurrent
47
+ AvailableForSaleSecuritiesDebtSecuritiesCurrent OtherCurrentFinancialAssets
48
+ CurrentFinancialAssetsAtAmortisedCost],
49
+ receivables: %w[AccountsReceivableNetCurrent ReceivablesNetCurrent CurrentTradeReceivables
50
+ TradeAndOtherCurrentReceivables AccountsNotesAndLoansReceivableNetCurrent AccountsReceivableNet],
51
+ inventory: %w[InventoryNet Inventories],
52
+ current_assets: %w[AssetsCurrent CurrentAssets],
53
+ ppe: %w[PropertyPlantAndEquipmentNet
54
+ PropertyPlantAndEquipmentAndFinanceLeaseRightOfUseAssetAfterAccumulatedDepreciationAndAmortization
55
+ PropertyPlantAndEquipment],
56
+ total_assets: %w[Assets],
57
+ payables: %w[AccountsPayableCurrent AccountsPayableTradeCurrent TradeAndOtherCurrentPayables
58
+ TradeAndOtherCurrentPayablesToTradeSuppliers AccountsPayableAndAccruedLiabilitiesCurrent
59
+ AccountsPayableAndAccruedLiabilitiesCurrentAndNoncurrent],
60
+ current_liabilities: %w[LiabilitiesCurrent CurrentLiabilities],
61
+ total_liabilities: %w[Liabilities],
62
+ short_term_debt: ['DebtCurrent',
63
+ %w[LongTermDebtCurrent CommercialPaper ShortTermBorrowings OtherShortTermBorrowings
64
+ ConvertibleDebtCurrent NotesPayableCurrent],
65
+ 'ShorttermBorrowings', 'CurrentBorrowings'],
66
+ long_term_debt: %w[LongTermDebtNoncurrent LongTermDebtAndCapitalLeaseObligations LongTermDebt LongTermNotesPayable
67
+ LongTermNotesAndLoans LongtermBorrowings NoncurrentBorrowings
68
+ LongTermDebtAndCapitalLeaseObligationsIncludingCurrentMaturities],
69
+ equity: %w[StockholdersEquity StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest
70
+ EquityAttributableToOwnersOfParent Equity],
71
+ retained_earnings: %w[RetainedEarningsAccumulatedDeficit RetainedEarnings],
72
+ shares_outstanding: %w[CommonStockSharesOutstanding NumberOfSharesOutstanding NumberOfSharesIssued
73
+ NumberOfSharesIssuedAndFullyPaid]
74
+ }.freeze
75
+
76
+ CASH_FLOW = {
77
+ operating_cash_flow: %w[NetCashProvidedByUsedInOperatingActivities
78
+ NetCashProvidedByUsedInOperatingActivitiesContinuingOperations
79
+ CashFlowsFromUsedInOperatingActivities],
80
+ capital_expenditure: %w[
81
+ PaymentsToAcquirePropertyPlantAndEquipment PaymentsToAcquireProductiveAssets
82
+ PurchaseOfPropertyPlantAndEquipmentClassifiedAsInvestingActivities
83
+ PurchaseOfPropertyPlantAndEquipmentIntangibleAssetsOtherThanGoodwillInvestmentPropertyAndOtherNoncurrentAssets
84
+ PaymentsToAcquireOtherPropertyPlantAndEquipment PaymentsToAcquireOtherProductiveAssets
85
+ ],
86
+ dividends_paid: %w[PaymentsOfDividends PaymentsOfDividendsCommonStock PaymentsOfOrdinaryDividends
87
+ DividendsCommonStockCash DividendsPaidClassifiedAsFinancingActivities
88
+ DividendsPaidToEquityHoldersOfParentClassifiedAsFinancingActivities DividendsPaid],
89
+ share_repurchases: %w[PaymentsForRepurchaseOfCommonStock PaymentsForRepurchaseOfEquity
90
+ TreasuryStockValueAcquiredCostMethod PaymentsForPurchaseOfTreasuryShares]
91
+ }.freeze
92
+
93
+ BANKING = {
94
+ net_interest_income: %w[InterestIncomeExpenseNet],
95
+ interest_income: %w[InterestAndDividendIncomeOperating InterestIncome InterestAndFeeIncomeLoansAndLeases],
96
+ provision_for_credit_losses: %w[ProvisionForLoanLeaseAndOtherLosses ProvisionForLoanAndLeaseLosses
97
+ ProvisionForCreditLosses ProvisionForLoanLossesExpensed],
98
+ noninterest_income: %w[NoninterestIncome],
99
+ noninterest_expense: %w[NoninterestExpense],
100
+ loans: %w[FinancingReceivableExcludingAccruedInterestAfterAllowanceForCreditLoss
101
+ LoansAndLeasesReceivableNetReportedAmount NotesReceivableNet LoansAndLeasesReceivableNetOfDeferredIncome],
102
+ deposits: %w[Deposits],
103
+ allowance_for_credit_losses: %w[FinancingReceivableAllowanceForCreditLossExcludingAccruedInterest
104
+ FinancingReceivableAllowanceForCreditLosses],
105
+ tier1_capital_ratio: %w[TierOneRiskBasedCapitalToRiskWeightedAssets]
106
+ }.freeze
107
+
108
+ # Cover page facts from the DEI taxonomy, dated by filing rather than
109
+ # by fiscal year end, used when the balance sheet lacks the item.
110
+ COVER = { shares_outstanding: 'EntityCommonStockSharesOutstanding' }.freeze
111
+
112
+ INSTANT = (BALANCE.keys + Banking::INSTANT).freeze
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fundamentalista
4
+ module Providers
5
+ # The SEC's EDGAR XBRL API. Free and keyless, but the SEC requires a
6
+ # User-Agent naming the application and a contact email, set through
7
+ # Configuration#edgar_user_agent. Serves annual periods from 10-K
8
+ # filings and quarterly ones from 10-Q filings, with fourth quarters
9
+ # derived from the annual figures; foreign filers reporting under IFRS
10
+ # in 20-F and 40-F filings get their annual periods in their own
11
+ # currency. It has no market data, so quotes come from the price you
12
+ # pass to Company#valuation. Requests are spaced to respect the SEC's
13
+ # rate limit, see Configuration#edgar_requests_per_second.
14
+ class Edgar < Provider
15
+ TICKERS_URL = 'https://www.sec.gov/files/company_tickers.json'
16
+ FACTS_URL = 'https://data.sec.gov/api/xbrl/companyfacts/CIK%010d.json'
17
+
18
+ def company(ticker)
19
+ symbol = ticker.to_s.upcase.tr('.', '-')
20
+ entry = tickers[symbol] || raise(CompanyNotFoundError, "EDGAR lists no company under #{ticker.inspect}")
21
+ Company.new(ticker: symbol, name: entry['title'], cik: entry['cik_str'], provider: self)
22
+ end
23
+
24
+ def financials(company, period: :annual, limit: 5)
25
+ facts = Facts.new(company_facts(company))
26
+ @currency = facts.currency
27
+ periods = case period
28
+ when :annual then facts.fiscal_years.first(limit).map { |year, ended_on| annual_period(facts, year, ended_on) }
29
+ when :quarterly then quarterly_periods(facts, limit)
30
+ else raise UnsupportedPeriodError, "EDGAR serves :annual and :quarterly periods, not #{period.inspect}"
31
+ end
32
+ Financials.new(periods)
33
+ end
34
+
35
+ def quote(_company)
36
+ nil
37
+ end
38
+
39
+ def estimates(_company)
40
+ []
41
+ end
42
+
43
+ private
44
+
45
+ STATEMENTS = { income: [IncomeStatement, Tags::INCOME], balance: [BalanceSheet, Tags::BALANCE],
46
+ cash_flow: [CashFlowStatement, Tags::CASH_FLOW], banking: [Banking, Tags::BANKING] }.freeze
47
+ private_constant :STATEMENTS
48
+
49
+ def annual_period(facts, year, ended_on)
50
+ build_period(year, ended_on) { |tags| facts.annual(tags, ended_on, year) }
51
+ end
52
+
53
+ def quarterly_periods(facts, limit)
54
+ quarters = facts.quarters
55
+ quarters.first(limit).each_with_index.map do |(year, quarter, ended_on), index|
56
+ previous = quarters[index + 1]&.last
57
+ build_period(year, ended_on, type: :quarterly, quarter: quarter) { |tags| facts.quarterly(tags, ended_on, previous) }
58
+ end
59
+ end
60
+
61
+ def build_period(year, ended_on, type: :annual, quarter: nil)
62
+ sources = {}
63
+ statements = STATEMENTS.transform_values do |klass, tags|
64
+ found = yield(tags)
65
+ found.each { |item, pair| sources[item] = pair.last if pair }
66
+ klass.new(**found.transform_values { |pair| pair&.first })
67
+ end
68
+ Period.new(fiscal_year: year, ended_on: ended_on, type: type, quarter: quarter, currency: @currency,
69
+ sources: sources, **statements)
70
+ end
71
+
72
+ def company_facts(company)
73
+ get(format(FACTS_URL, company.cik), headers: headers)
74
+ rescue CompanyNotFoundError => e
75
+ raise CompanyNotFoundError.new("EDGAR has no XBRL facts for #{company.ticker}; it may not file financial statements",
76
+ response: e.response)
77
+ end
78
+
79
+ def tickers
80
+ @tickers ||= get(TICKERS_URL, headers: headers).values.to_h { |entry| [entry['ticker'], entry] }
81
+ end
82
+
83
+ def fetch_json(url, params, headers)
84
+ self.class.throttle(config.edgar_requests_per_second)
85
+ super
86
+ end
87
+
88
+ class << self
89
+ # Spaces requests out to at most +per_second+ across the process.
90
+ def throttle(per_second)
91
+ @mutex ||= Mutex.new
92
+ @mutex.synchronize do
93
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
94
+ wait = @last_request_at ? (@last_request_at + (1.0 / per_second)) - now : 0
95
+ sleep(wait) if wait.positive?
96
+ @last_request_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
97
+ end
98
+ end
99
+ end
100
+
101
+ def headers
102
+ agent = config.edgar_user_agent
103
+ raise ConfigurationError, 'The SEC requires config.edgar_user_agent, an app name and a contact email' unless agent
104
+
105
+ { 'User-Agent' => agent }
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fundamentalista
4
+ module Providers
5
+ # Financial Modeling Prep. Needs Configuration#fmp_api_key. Serves
6
+ # annual and quarterly periods, company profiles, live quotes and
7
+ # analyst estimates.
8
+ class FMP < Provider
9
+ BASE_URL = 'https://financialmodelingprep.com/stable/'
10
+
11
+ def company(ticker)
12
+ profile = get_first('profile', symbol: ticker)
13
+ raise CompanyNotFoundError, "FMP lists no company under #{ticker.inspect}" unless profile
14
+
15
+ Company.new(ticker: profile['symbol'], name: profile['companyName'], cik: profile['cik']&.to_i,
16
+ currency: profile['currency'], provider: self)
17
+ end
18
+
19
+ def financials(company, period: :annual, limit: 5)
20
+ params = { symbol: company.ticker, period: period == :quarterly ? 'quarter' : 'annual', limit: limit }
21
+ statements = %w[income-statement balance-sheet-statement cash-flow-statement].map do |endpoint|
22
+ by_date(fetch(endpoint, params))
23
+ end
24
+ Financials.new(statements.first.keys.map { |date| build_period(date, *statements.map { |rows| rows[date] || {} }) })
25
+ end
26
+
27
+ def quote(company)
28
+ row = get_first('quote', symbol: company.ticker)
29
+ raise QuoteUnavailableError, "FMP has no quote for #{company.ticker}" unless row
30
+
31
+ Quote.new(price: row['price'], market_cap: row['marketCap'], currency: company.currency,
32
+ as_of: row['timestamp'] && Time.at(row['timestamp']))
33
+ end
34
+
35
+ def estimates(company)
36
+ fetch('analyst-estimates', symbol: company.ticker, period: 'annual', limit: 10).map do |row|
37
+ Estimate.new(fiscal_year_end: Date.parse(row['date']), eps: row['epsAvg'], eps_low: row['epsLow'],
38
+ eps_high: row['epsHigh'], revenue: row['revenueAvg'], ebitda: row['ebitdaAvg'],
39
+ net_income: row['netIncomeAvg'], analysts: row['numAnalystsEps'])
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def build_period(date, income, balance, cash_flow)
46
+ Period.new(
47
+ fiscal_year: income['fiscalYear'].to_i,
48
+ ended_on: Date.parse(date),
49
+ type: income['period'] == 'FY' ? :annual : :quarterly,
50
+ quarter: income['period'][/\AQ(\d)\z/, 1]&.to_i,
51
+ currency: income['reportedCurrency'] || 'USD',
52
+ income: income_statement(income),
53
+ balance: balance_sheet(balance),
54
+ cash_flow: cash_flow_statement(cash_flow),
55
+ banking: Banking.new(net_interest_income: income['netInterestIncome'], interest_income: income['interestIncome'])
56
+ )
57
+ end
58
+
59
+ def income_statement(row)
60
+ IncomeStatement.new(
61
+ revenue: row['revenue'], cost_of_revenue: row['costOfRevenue'], gross_profit: row['grossProfit'],
62
+ sga: row['sellingGeneralAndAdministrativeExpenses'],
63
+ operating_income: row['operatingIncome'], interest_expense: row['interestExpense'],
64
+ income_before_tax: row['incomeBeforeTax'], income_tax: row['incomeTaxExpense'],
65
+ net_income: row['netIncome'], depreciation_amortization: row['depreciationAndAmortization'],
66
+ ebitda: row['ebitda'], eps_diluted: row['epsDiluted'], diluted_shares: row['weightedAverageShsOutDil']
67
+ )
68
+ end
69
+
70
+ def balance_sheet(row)
71
+ BalanceSheet.new(
72
+ cash: row['cashAndCashEquivalents'], short_term_investments: row['shortTermInvestments'],
73
+ receivables: row['netReceivables'], inventory: row['inventory'],
74
+ current_assets: row['totalCurrentAssets'], ppe: row['propertyPlantEquipmentNet'], total_assets: row['totalAssets'],
75
+ payables: row['accountPayables'],
76
+ current_liabilities: row['totalCurrentLiabilities'], total_liabilities: row['totalLiabilities'],
77
+ short_term_debt: row['shortTermDebt'], long_term_debt: row['longTermDebt'],
78
+ total_debt: row['totalDebt'], equity: row['totalStockholdersEquity'],
79
+ retained_earnings: row['retainedEarnings']
80
+ )
81
+ end
82
+
83
+ def cash_flow_statement(row)
84
+ CashFlowStatement.new(
85
+ operating_cash_flow: row['operatingCashFlow'], capital_expenditure: outflow(row['capitalExpenditure']),
86
+ free_cash_flow: row['freeCashFlow'], dividends_paid: outflow(row['netDividendsPaid']),
87
+ share_repurchases: outflow(row['commonStockRepurchased'])
88
+ )
89
+ end
90
+
91
+ def outflow(value)
92
+ value&.abs
93
+ end
94
+
95
+ def by_date(rows)
96
+ rows.to_h { |row| [row['date'], row] }
97
+ end
98
+
99
+ def get_first(endpoint, params)
100
+ fetch(endpoint, params).first
101
+ end
102
+
103
+ def fetch(endpoint, params)
104
+ key = config.fmp_api_key || raise(ConfigurationError, 'Set config.fmp_api_key to use Financial Modeling Prep')
105
+ Array(get("#{BASE_URL}#{endpoint}", params.merge(apikey: key)))
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fundamentalista
4
+ # A market snapshot: price, shares outstanding and market capitalization.
5
+ # Any two of them imply the third.
6
+ class Quote
7
+ include Inspectable
8
+ include Serializable
9
+
10
+ attr_reader :price, :currency, :as_of
11
+
12
+ def initialize(price:, shares_outstanding: nil, market_cap: nil, currency: 'USD', as_of: nil)
13
+ @price = Decimal.wrap(price)
14
+ @shares_outstanding = Decimal.wrap(shares_outstanding)
15
+ @market_cap = Decimal.wrap(market_cap)
16
+ @currency = currency
17
+ @as_of = as_of
18
+ end
19
+
20
+ def shares_outstanding
21
+ @shares_outstanding || Decimal.ratio(@market_cap, price)
22
+ end
23
+
24
+ def market_cap
25
+ @market_cap || (@shares_outstanding && price && (@shares_outstanding * price))
26
+ end
27
+
28
+ def inspect_attributes # :nodoc:
29
+ { price: price, market_cap: market_cap, currency: currency, as_of: as_of }
30
+ end
31
+ end
32
+ end