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