ruby-edgar 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/README.md +120 -0
- data/lib/edgar/attachment.rb +69 -0
- data/lib/edgar/attachments.rb +75 -0
- data/lib/edgar/company.rb +217 -0
- data/lib/edgar/config.rb +62 -0
- data/lib/edgar/data_objects.rb +521 -0
- data/lib/edgar/entity.rb +98 -0
- data/lib/edgar/entity_data.rb +189 -0
- data/lib/edgar/entity_facts.rb +322 -0
- data/lib/edgar/filing.rb +170 -0
- data/lib/edgar/filing_facts.rb +312 -0
- data/lib/edgar/filing_header.rb +124 -0
- data/lib/edgar/filing_homepage.rb +118 -0
- data/lib/edgar/filing_index.rb +87 -0
- data/lib/edgar/filing_sections.rb +43 -0
- data/lib/edgar/filings.rb +216 -0
- data/lib/edgar/financials.rb +361 -0
- data/lib/edgar/http_client.rb +116 -0
- data/lib/edgar/ttm_calculator.rb +185 -0
- data/lib/edgar/version.rb +5 -0
- data/lib/edgar/xbrl/xbrl.rb +134 -0
- data/lib/edgar.rb +83 -0
- metadata +164 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Edgar
|
|
4
|
+
# Enumerable collection of Filing objects with filter, sort, and summary.
|
|
5
|
+
class Filings
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
attr_reader :entries, :original_state, :per_page
|
|
9
|
+
|
|
10
|
+
def initialize(entries = [], original_state: {}, page_index: 0, per_page: 100,
|
|
11
|
+
all_entries: nil)
|
|
12
|
+
@page_index = page_index
|
|
13
|
+
@per_page = per_page
|
|
14
|
+
@all_entries = all_entries
|
|
15
|
+
@entries = entries.map do |e|
|
|
16
|
+
if e.is_a?(Filing)
|
|
17
|
+
e
|
|
18
|
+
elsif e.is_a?(Hash) || e.is_a?(FilingIndex::IndexEntry)
|
|
19
|
+
Filing.new(
|
|
20
|
+
cik: e[:cik] || e["cik"],
|
|
21
|
+
company: e[:company] || e["company"],
|
|
22
|
+
form: e[:form] || e["form"],
|
|
23
|
+
filing_date: e[:filing_date] || e["filing_date"],
|
|
24
|
+
accession_number: e[:accession_number] || e["accession_number"]
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end.compact
|
|
28
|
+
@all_entries ||= @entries
|
|
29
|
+
@original_state = original_state
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def each(&block)
|
|
33
|
+
@entries.each(&block)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def [](index)
|
|
37
|
+
if index.is_a?(Integer)
|
|
38
|
+
@entries[index]
|
|
39
|
+
else
|
|
40
|
+
@entries.find { |f| f.accession_number == index }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def length
|
|
45
|
+
@entries.length
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def empty?
|
|
49
|
+
@entries.empty?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def latest(count = 1)
|
|
53
|
+
sorted = @entries.sort_by(&:filing_date).reverse
|
|
54
|
+
selected = sorted.first(count)
|
|
55
|
+
return selected.first if count == 1 && selected.length == 1
|
|
56
|
+
|
|
57
|
+
Filings.new(selected, original_state: @original_state)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def head(count = 10)
|
|
61
|
+
Filings.new(@entries.first(count), original_state: @original_state)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def tail(count = 10)
|
|
65
|
+
Filings.new(@entries.last(count), original_state: @original_state)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def sample(count = 1)
|
|
69
|
+
Filings.new(@entries.sample(count), original_state: @original_state)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def filter(form: nil, filing_date: nil, date: nil, cik: nil, ticker: nil,
|
|
73
|
+
exchange: nil, company_name: nil,
|
|
74
|
+
amendments: nil, date_start: nil, date_end: nil)
|
|
75
|
+
filtered = @entries.select do |f|
|
|
76
|
+
matches_filters?(f,
|
|
77
|
+
form: form, cik: cik, ticker: ticker, company_name: company_name,
|
|
78
|
+
exchange: exchange, filing_date: filing_date || date,
|
|
79
|
+
date_start: date_start, date_end: date_end,
|
|
80
|
+
amendments: amendments)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
Filings.new(filtered, original_state: @original_state)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def find_by_company(company_name)
|
|
87
|
+
filter(company_name: company_name)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def current_page
|
|
91
|
+
@page_index + 1
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def page(num = nil, per_page: nil)
|
|
95
|
+
p = num || (@page_index + 1)
|
|
96
|
+
pp = per_page || @per_page
|
|
97
|
+
start_idx = (p - 1) * pp
|
|
98
|
+
page_entries = @all_entries[start_idx, pp] || []
|
|
99
|
+
Filings.new(page_entries, original_state: @original_state,
|
|
100
|
+
page_index: p - 1, per_page: pp,
|
|
101
|
+
all_entries: @all_entries)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def next_page
|
|
105
|
+
next_start = (@page_index + 1) * @per_page
|
|
106
|
+
return self if next_start >= @all_entries.length
|
|
107
|
+
|
|
108
|
+
page(@page_index + 2)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def previous_page
|
|
112
|
+
return self if @page_index.zero?
|
|
113
|
+
|
|
114
|
+
page(@page_index)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def total_pages(per_page: nil)
|
|
118
|
+
pp = per_page || @per_page
|
|
119
|
+
pp.positive? ? (@all_entries.length.to_f / pp).ceil : 0
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def group_by_form
|
|
123
|
+
@entries.group_by(&:form)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def to_a
|
|
127
|
+
@entries.dup
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def to_hashes
|
|
131
|
+
@entries.map do |f|
|
|
132
|
+
{
|
|
133
|
+
cik: f.cik,
|
|
134
|
+
company: f.company,
|
|
135
|
+
form: f.form,
|
|
136
|
+
filing_date: f.filing_date,
|
|
137
|
+
accession_number: f.accession_number
|
|
138
|
+
}
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def to_dict
|
|
143
|
+
to_hashes
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def date_range
|
|
147
|
+
return nil if @entries.empty?
|
|
148
|
+
|
|
149
|
+
dates = @entries.map(&:filing_date)
|
|
150
|
+
{ min: dates.min, max: dates.max }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def self.from_quarter(year, quarter, type: "company")
|
|
154
|
+
entries = FilingIndex.for(year, quarter, type: type)
|
|
155
|
+
new(entries)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self.from_years(years, quarters: [1, 2, 3, 4])
|
|
159
|
+
years = Array(years)
|
|
160
|
+
all = years.flat_map do |y|
|
|
161
|
+
quarters.flat_map { |q| FilingIndex.for(y, q) }
|
|
162
|
+
end
|
|
163
|
+
new(all)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def summary
|
|
167
|
+
form_counts = group_by_form.transform_values(&:length)
|
|
168
|
+
date_range_str = if @entries.any?
|
|
169
|
+
dates = @entries.map(&:filing_date)
|
|
170
|
+
"#{dates.min} - #{dates.max}"
|
|
171
|
+
else
|
|
172
|
+
"N/A"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
{
|
|
176
|
+
count: @entries.length,
|
|
177
|
+
date_range: date_range_str,
|
|
178
|
+
form_counts: form_counts
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
def matches_filters?(filing, form: nil, cik: nil, ticker: nil, company_name: nil,
|
|
185
|
+
exchange: nil, filing_date: nil,
|
|
186
|
+
date_start: nil, date_end: nil, amendments: nil)
|
|
187
|
+
if form
|
|
188
|
+
form_list = Array(form)
|
|
189
|
+
# Match Python edgartools default behavior: include amendment (/A) variants
|
|
190
|
+
# unless amendments are explicitly excluded.
|
|
191
|
+
form_list = form_list.flat_map { |f| [f, "#{f}/A"] }.uniq unless amendments == false
|
|
192
|
+
return false unless form_list.include?(filing.form)
|
|
193
|
+
end
|
|
194
|
+
return false if cik && filing.cik != cik.to_i
|
|
195
|
+
return false if ticker && !filing.company&.downcase&.include?(ticker.downcase)
|
|
196
|
+
return false if company_name && !filing.company&.downcase&.include?(company_name.downcase)
|
|
197
|
+
return false if exchange && !exchange_match?(filing, exchange)
|
|
198
|
+
return false if filing_date && parse_date(filing_date) != filing.filing_date
|
|
199
|
+
return false if date_start && filing.filing_date < parse_date(date_start)
|
|
200
|
+
return false if date_end && filing.filing_date > parse_date(date_end)
|
|
201
|
+
return false if amendments == false && filing.form.to_s.end_with?("/A")
|
|
202
|
+
|
|
203
|
+
true
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def exchange_match?(filing, exchange)
|
|
207
|
+
ex = Array(exchange).map(&:downcase)
|
|
208
|
+
entity_exchanges = EntityData.new(cik: filing.cik).exchanges.map(&:downcase)
|
|
209
|
+
(ex & entity_exchanges).any?
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def parse_date(value)
|
|
213
|
+
value.is_a?(Date) ? value : Date.parse(value.to_s)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Edgar
|
|
4
|
+
# Wraps EntityFacts/FilingFacts with statement rendering and convenience
|
|
5
|
+
# accessors matching Python edgartools' Financials API.
|
|
6
|
+
class Financials
|
|
7
|
+
# Map ISO 4217 currency codes to symbols (matches Python edgartools)
|
|
8
|
+
CURRENCY_SYMBOLS = {
|
|
9
|
+
"USD" => "$",
|
|
10
|
+
"EUR" => "\u20AC",
|
|
11
|
+
"GBP" => "\u00A3",
|
|
12
|
+
"JPY" => "\u00A5",
|
|
13
|
+
"CAD" => "C$",
|
|
14
|
+
"CHF" => "Fr",
|
|
15
|
+
"AUD" => "A$",
|
|
16
|
+
"CNY" => "\u00A5",
|
|
17
|
+
"HKD" => "HK$",
|
|
18
|
+
"NZD" => "NZ$",
|
|
19
|
+
"SEK" => "kr",
|
|
20
|
+
"NOK" => "kr",
|
|
21
|
+
"DKK" => "kr",
|
|
22
|
+
"INR" => "\u20B9",
|
|
23
|
+
"BRL" => "R$",
|
|
24
|
+
"ZAR" => "R",
|
|
25
|
+
"MXN" => "Mex$",
|
|
26
|
+
"SGD" => "S$",
|
|
27
|
+
"TWD" => "NT$",
|
|
28
|
+
"KRW" => "\u20A9",
|
|
29
|
+
"RUB" => "\u20BD",
|
|
30
|
+
"PLN" => "z\u0142",
|
|
31
|
+
"TRY" => "\u20BA",
|
|
32
|
+
"ILS" => "\u20AA"
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
attr_reader :facts
|
|
36
|
+
|
|
37
|
+
def initialize(cik: nil, facts: nil)
|
|
38
|
+
@facts = facts || EntityFacts.new(cik: cik)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def available?
|
|
42
|
+
@facts.available?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def income_statement(periods: 4)
|
|
46
|
+
concepts = %w[Revenues Revenue
|
|
47
|
+
RevenueFromContractWithCustomerExcludingAssessedTax
|
|
48
|
+
RevenueFromContractWithCustomer
|
|
49
|
+
CostOfGoodsAndServicesSold
|
|
50
|
+
GrossProfit OperatingExpenses
|
|
51
|
+
OperatingIncomeLoss
|
|
52
|
+
NonoperatingIncomeExpensePlusInterestAndDividendIncome
|
|
53
|
+
IncomeLossFromContinuingOperationsBeforeIncomeTaxExpenseBenefit
|
|
54
|
+
IncomeTaxExpenseBenefit
|
|
55
|
+
NetIncomeLoss
|
|
56
|
+
EarningsPerShareBasic
|
|
57
|
+
EarningsPerShareDiluted
|
|
58
|
+
WeightedAverageNumberOfSharesOutstandingBasic
|
|
59
|
+
WeightedAverageNumberOfDilutedSharesOutstanding]
|
|
60
|
+
extract_concepts(concepts, periods)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def balance_sheet(periods: 4)
|
|
64
|
+
concepts = %w[Assets CurrentAssets
|
|
65
|
+
CashAndCashEquivalentsAtCarryingValue
|
|
66
|
+
AccountsReceivableNetCurrent
|
|
67
|
+
InventoryNet
|
|
68
|
+
PropertyPlantAndEquipmentNet
|
|
69
|
+
AssetsCurrent LiabilitiesCurrent
|
|
70
|
+
LongTermDebtCurrent LongTermDebtNoncurrent
|
|
71
|
+
Liabilities StockholdersEquity
|
|
72
|
+
StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest
|
|
73
|
+
RetainedEarningsAccumulatedDeficit
|
|
74
|
+
CommonStocksIncludingAdditionalPaidInCapital
|
|
75
|
+
TreasuryStockValue]
|
|
76
|
+
extract_concepts(concepts, periods)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def cashflow_statement(periods: 4)
|
|
80
|
+
concepts = %w[NetCashProvidedByUsedInOperatingActivities
|
|
81
|
+
NetCashProvidedByUsedInInvestingActivities
|
|
82
|
+
NetCashProvidedByUsedInFinancingActivities
|
|
83
|
+
CashAndCashEquivalentsPeriodIncreaseDecrease
|
|
84
|
+
CashAndCashEquivalentsAtCarryingValue]
|
|
85
|
+
extract_concepts(concepts, periods)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def statement_of_equity(periods: 4)
|
|
89
|
+
concepts = %w[StockholdersEquity
|
|
90
|
+
StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest
|
|
91
|
+
RetainedEarningsAccumulatedDeficit
|
|
92
|
+
CommonStocksIncludingAdditionalPaidInCapital
|
|
93
|
+
TreasuryStockValue
|
|
94
|
+
AccumulatedOtherComprehensiveIncomeLossNetOfTax
|
|
95
|
+
ComprehensiveIncomeNetOfTax
|
|
96
|
+
Dividends]
|
|
97
|
+
extract_concepts(concepts, periods)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def comprehensive_income(periods: 4)
|
|
101
|
+
concepts = %w[ComprehensiveIncomeNetOfTax
|
|
102
|
+
OtherComprehensiveIncomeLossNetOfTax
|
|
103
|
+
ComprehensiveIncomeNetOfTaxAttributableToParent
|
|
104
|
+
ComprehensiveIncomeNetOfTaxAttributableToNoncontrollingInterest]
|
|
105
|
+
extract_concepts(concepts, periods)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Delegates to EntityFacts/FilingFacts for the 7 core EntityFacts accessors
|
|
109
|
+
def get_revenue(limit: nil, annual: true, period: nil)
|
|
110
|
+
@facts.get_revenue(limit: limit, annual: annual, period: period)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def get_net_income(limit: nil, annual: true, period: nil)
|
|
114
|
+
@facts.get_net_income(limit: limit, annual: annual, period: period)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def get_operating_income(limit: nil, annual: true, period: nil)
|
|
118
|
+
@facts.get_operating_income(limit: limit, annual: annual, period: period)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def get_total_assets(limit: nil, annual: true, period: nil)
|
|
122
|
+
@facts.get_total_assets(limit: limit, annual: annual, period: period)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def get_total_liabilities(limit: nil, annual: true, period: nil)
|
|
126
|
+
@facts.get_total_liabilities(limit: limit, annual: annual, period: period)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def get_shareholders_equity(limit: nil, annual: true, period: nil)
|
|
130
|
+
@facts.get_shareholders_equity(limit: limit, annual: annual, period: period)
|
|
131
|
+
end
|
|
132
|
+
alias get_stockholders_equity get_shareholders_equity
|
|
133
|
+
|
|
134
|
+
def get_gross_profit(limit: nil, annual: true, period: nil)
|
|
135
|
+
@facts.get_gross_profit(limit: limit, annual: annual, period: period)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Financials-specific convenience accessors matching Python edgartools
|
|
139
|
+
# Financials API. These use @facts._select_value and @facts.concept_values
|
|
140
|
+
# since Python Financials implements them via XBRL statement rendering
|
|
141
|
+
# (not available in ruby-edgar yet).
|
|
142
|
+
|
|
143
|
+
def get_operating_cash_flow(limit: nil, annual: true, period: nil)
|
|
144
|
+
if limit
|
|
145
|
+
v = @facts.concept_values("NetCashProvidedByUsedInOperatingActivities", limit: limit, annual: annual)
|
|
146
|
+
v.empty? ? nil : v
|
|
147
|
+
else
|
|
148
|
+
@facts._select_value("NetCashProvidedByUsedInOperatingActivities", annual: annual, period: period)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def get_free_cash_flow(limit: nil, annual: true)
|
|
153
|
+
op = @facts._select_value("NetCashProvidedByUsedInOperatingActivities", annual: annual)
|
|
154
|
+
capex = @facts._select_value("PaymentsToAcquirePropertyPlantAndEquipment", annual: annual)
|
|
155
|
+
return nil unless op && capex
|
|
156
|
+
|
|
157
|
+
if limit
|
|
158
|
+
end_date = @facts.concept_values(
|
|
159
|
+
"NetCashProvidedByUsedInOperatingActivities", limit: 1, annual: annual
|
|
160
|
+
)&.first&.dig("end")
|
|
161
|
+
[{ "end" => end_date, "val" => op - capex.abs }]
|
|
162
|
+
else
|
|
163
|
+
op - capex.abs
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def get_earnings_per_share(limit: nil, annual: true, period: nil)
|
|
168
|
+
if limit
|
|
169
|
+
v = @facts.concept_values("EarningsPerShareDiluted", limit: limit, annual: annual)
|
|
170
|
+
v = @facts.concept_values("EarningsPerShareBasic", limit: limit, annual: annual) if v.empty?
|
|
171
|
+
v.empty? ? nil : v
|
|
172
|
+
else
|
|
173
|
+
val = @facts._select_value("EarningsPerShareDiluted", annual: annual, period: period)
|
|
174
|
+
val || @facts._select_value("EarningsPerShareBasic", annual: annual, period: period)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def get_current_assets(limit: nil, annual: true, period: nil)
|
|
179
|
+
if limit
|
|
180
|
+
v = @facts.concept_values("AssetsCurrent", limit: limit, annual: annual)
|
|
181
|
+
v.empty? ? nil : v
|
|
182
|
+
else
|
|
183
|
+
@facts._select_value("AssetsCurrent", annual: annual, period: period)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def get_current_liabilities(limit: nil, annual: true, period: nil)
|
|
188
|
+
if limit
|
|
189
|
+
v = @facts.concept_values("LiabilitiesCurrent", limit: limit, annual: annual)
|
|
190
|
+
v.empty? ? nil : v
|
|
191
|
+
else
|
|
192
|
+
@facts._select_value("LiabilitiesCurrent", annual: annual, period: period)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def get_capital_expenditures(limit: nil, annual: true, period: nil)
|
|
197
|
+
if limit
|
|
198
|
+
v = @facts.concept_values("PaymentsToAcquirePropertyPlantAndEquipment", limit: limit, annual: annual)
|
|
199
|
+
v.empty? ? nil : v
|
|
200
|
+
else
|
|
201
|
+
@facts._select_value("PaymentsToAcquirePropertyPlantAndEquipment", annual: annual, period: period)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def get_shares_outstanding_basic(limit: nil, annual: true, period: nil)
|
|
206
|
+
concepts = %w[WeightedAverageNumberOfSharesOutstandingBasic
|
|
207
|
+
EntityCommonStockSharesOutstanding
|
|
208
|
+
CommonStockSharesOutstanding]
|
|
209
|
+
if limit
|
|
210
|
+
concepts.each do |name|
|
|
211
|
+
v = @facts.concept_values(name, limit: limit, annual: annual)
|
|
212
|
+
return v unless v.empty?
|
|
213
|
+
end
|
|
214
|
+
else
|
|
215
|
+
concepts.each do |name|
|
|
216
|
+
val = @facts._select_value(name, annual: annual, period: period)
|
|
217
|
+
return val if val
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def get_shares_outstanding_diluted(limit: nil, annual: true, period: nil)
|
|
224
|
+
if limit
|
|
225
|
+
v = @facts.concept_values("WeightedAverageNumberOfDilutedSharesOutstanding", limit: limit, annual: annual)
|
|
226
|
+
v = @facts.concept_values("DilutedAverageShares", limit: limit, annual: annual) if v.empty?
|
|
227
|
+
v.empty? ? nil : v
|
|
228
|
+
else
|
|
229
|
+
val = @facts._select_value("WeightedAverageNumberOfDilutedSharesOutstanding", annual: annual, period: period)
|
|
230
|
+
val || @facts._select_value("DilutedAverageShares", annual: annual, period: period)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def currency_symbol
|
|
235
|
+
# Try to find the currency from us-gaap units.
|
|
236
|
+
# EntityFacts returns Hash-format concepts with a "units" sub-hash.
|
|
237
|
+
# FilingFacts returns Array-format concepts with a "unit" string key.
|
|
238
|
+
@facts.us_gaap.each_value do |concept|
|
|
239
|
+
if concept.is_a?(Array)
|
|
240
|
+
# FilingFacts format: each element is a fact hash with "unit" key
|
|
241
|
+
concept.each do |v|
|
|
242
|
+
result = currency_from_unit(v["unit"])
|
|
243
|
+
return result if result
|
|
244
|
+
end
|
|
245
|
+
else
|
|
246
|
+
# EntityFacts format: concept hash with "units" sub-hash
|
|
247
|
+
units = concept["units"]
|
|
248
|
+
next unless units
|
|
249
|
+
|
|
250
|
+
units.each_key do |unit|
|
|
251
|
+
result = currency_from_unit(unit)
|
|
252
|
+
return result if result
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Fallback: try facts_map when us_gaap is empty (e.g. custom facts objects)
|
|
258
|
+
if @facts.respond_to?(:facts_map)
|
|
259
|
+
@facts.facts_map.each_value do |values|
|
|
260
|
+
next unless values.any?
|
|
261
|
+
|
|
262
|
+
values.each do |v|
|
|
263
|
+
result = currency_from_unit(v["unit"])
|
|
264
|
+
return result if result
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Default fallback (Python edgartools defaults to "$")
|
|
270
|
+
"$"
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def financial_metrics
|
|
274
|
+
revenue = get_revenue
|
|
275
|
+
net_income = get_net_income
|
|
276
|
+
operating_income = get_operating_income
|
|
277
|
+
total_assets = get_total_assets
|
|
278
|
+
total_liabilities = get_total_liabilities
|
|
279
|
+
equity = get_stockholders_equity
|
|
280
|
+
ocf = get_operating_cash_flow
|
|
281
|
+
fcf = get_free_cash_flow
|
|
282
|
+
gross_profit = get_gross_profit
|
|
283
|
+
eps = get_earnings_per_share
|
|
284
|
+
current_assets = get_current_assets
|
|
285
|
+
current_liabilities = get_current_liabilities
|
|
286
|
+
capex = get_capital_expenditures
|
|
287
|
+
shares_basic = get_shares_outstanding_basic
|
|
288
|
+
shares_diluted = get_shares_outstanding_diluted
|
|
289
|
+
|
|
290
|
+
metrics = {
|
|
291
|
+
revenue: revenue,
|
|
292
|
+
operating_income: operating_income,
|
|
293
|
+
net_income: net_income,
|
|
294
|
+
total_assets: total_assets,
|
|
295
|
+
total_liabilities: total_liabilities,
|
|
296
|
+
stockholders_equity: equity,
|
|
297
|
+
current_assets: current_assets,
|
|
298
|
+
current_liabilities: current_liabilities,
|
|
299
|
+
operating_cash_flow: ocf,
|
|
300
|
+
capital_expenditures: capex,
|
|
301
|
+
free_cash_flow: fcf,
|
|
302
|
+
shares_outstanding_basic: shares_basic,
|
|
303
|
+
shares_outstanding_diluted: shares_diluted,
|
|
304
|
+
gross_profit: gross_profit,
|
|
305
|
+
eps: eps
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
# Calculate ratios (matching Python edgartools)
|
|
309
|
+
if current_assets && current_liabilities&.positive?
|
|
310
|
+
metrics[:current_ratio] = current_assets / current_liabilities.to_f
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
metrics[:debt_to_assets] = total_liabilities / total_assets.to_f if total_liabilities && total_assets&.positive?
|
|
314
|
+
|
|
315
|
+
metrics
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
class << self
|
|
319
|
+
def extract(filing)
|
|
320
|
+
xbrl = filing.xbrl
|
|
321
|
+
facts = if xbrl
|
|
322
|
+
FilingFacts.new(xbrl, form_type: filing.form)
|
|
323
|
+
else
|
|
324
|
+
EntityFacts.new(cik: filing.cik)
|
|
325
|
+
end
|
|
326
|
+
new(facts: facts)
|
|
327
|
+
rescue StandardError => e
|
|
328
|
+
warn "Failed to extract financials from filing: #{e.message}"
|
|
329
|
+
nil
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
private
|
|
334
|
+
|
|
335
|
+
# Look up a currency unit string in CURRENCY_SYMBOLS, returning either
|
|
336
|
+
# the mapped symbol or the raw unit if it looks like a currency code.
|
|
337
|
+
def currency_from_unit(unit)
|
|
338
|
+
return nil unless unit.is_a?(String)
|
|
339
|
+
|
|
340
|
+
sym = CURRENCY_SYMBOLS[unit]
|
|
341
|
+
return sym if sym
|
|
342
|
+
return unit if unit.length <= 3 && unit == unit.upcase
|
|
343
|
+
|
|
344
|
+
nil
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def extract_concepts(concept_names, periods)
|
|
348
|
+
result = {}
|
|
349
|
+
concept_names.each do |name|
|
|
350
|
+
values = @facts.concept_values(name, limit: periods)
|
|
351
|
+
result[name] = { label: concept_label(name), values: values } if values.any?
|
|
352
|
+
end
|
|
353
|
+
result
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def concept_label(concept_name)
|
|
357
|
+
concept = @facts.concept(concept_name)
|
|
358
|
+
concept ? concept["label"] : concept_name
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/retry"
|
|
5
|
+
require "json"
|
|
6
|
+
require "zlib"
|
|
7
|
+
require "stringio"
|
|
8
|
+
|
|
9
|
+
module Edgar
|
|
10
|
+
# Faraday-based HTTP client with rate limiting, retries, and gzip handling.
|
|
11
|
+
class HTTPClient
|
|
12
|
+
class RateLimitError < StandardError; end
|
|
13
|
+
class SslVerificationError < StandardError; end
|
|
14
|
+
|
|
15
|
+
attr_reader :last_request_time
|
|
16
|
+
|
|
17
|
+
def initialize(
|
|
18
|
+
rate_limit: Config::EDGAR_RATE_LIMIT_PER_SEC,
|
|
19
|
+
timeout: Config::DEFAULT_TIMEOUT,
|
|
20
|
+
connect_timeout: Config::DEFAULT_CONNECT_TIMEOUT,
|
|
21
|
+
retries: 5
|
|
22
|
+
)
|
|
23
|
+
@rate_limit = rate_limit
|
|
24
|
+
@min_interval = 1.0 / rate_limit
|
|
25
|
+
@last_request_time = 0.0
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
@retries = retries
|
|
28
|
+
|
|
29
|
+
@connection = build_connection(timeout, connect_timeout)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def get(url, **options)
|
|
33
|
+
request(:get, url, **options)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def post(url, body: nil, **options)
|
|
37
|
+
request(:post, url, body: body, **options)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def download_text(url)
|
|
41
|
+
response = get(url)
|
|
42
|
+
decompress_body(url, response.body, response.headers)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def download_json(url)
|
|
46
|
+
response = get(url)
|
|
47
|
+
body = decompress_body(url, response.body, response.headers)
|
|
48
|
+
JSON.parse(body)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
GZIP_MAGIC = [0x1f, 0x8b].freeze
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def build_connection(timeout, connect_timeout)
|
|
56
|
+
Faraday.new do |f|
|
|
57
|
+
f.options[:timeout] = timeout
|
|
58
|
+
f.options[:open_timeout] = connect_timeout
|
|
59
|
+
|
|
60
|
+
f.request :retry, {
|
|
61
|
+
max: @retries,
|
|
62
|
+
interval: 1.0,
|
|
63
|
+
interval_randomness: 0.5,
|
|
64
|
+
backoff_factor: 2.0,
|
|
65
|
+
retry_statuses: [429, 500, 502, 503, 504],
|
|
66
|
+
retry_if: lambda { |env, _ex|
|
|
67
|
+
env.response&.status == 429 || env.response&.status&.between?(500, 599)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
f.adapter Faraday.default_adapter
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def request(method, url, body: nil, **options)
|
|
76
|
+
rate_limit!
|
|
77
|
+
|
|
78
|
+
headers = {
|
|
79
|
+
"User-Agent" => Config.user_agent,
|
|
80
|
+
"Accept-Encoding" => "gzip, deflate"
|
|
81
|
+
}
|
|
82
|
+
headers.merge!(options[:headers]) if options[:headers]
|
|
83
|
+
|
|
84
|
+
response = @connection.run_request(method, url, body, headers)
|
|
85
|
+
|
|
86
|
+
raise RateLimitError, "429 Too Many Requests" if response.status == 429
|
|
87
|
+
raise SslVerificationError, "SSL verification failed for #{url}" if response.status == 525
|
|
88
|
+
|
|
89
|
+
raise "HTTP #{response.status} for #{url}" unless response.success?
|
|
90
|
+
|
|
91
|
+
response
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def decompress_body(url, body, headers)
|
|
95
|
+
content_encoding = headers["content-encoding"].to_s.downcase
|
|
96
|
+
is_gz_url = url.to_s.end_with?(".gz")
|
|
97
|
+
is_gzip = content_encoding.include?("gzip") || is_gz_url
|
|
98
|
+
|
|
99
|
+
if is_gzip || body.bytes.first(2) == GZIP_MAGIC
|
|
100
|
+
Zlib::GzipReader.new(StringIO.new(body)).read
|
|
101
|
+
else
|
|
102
|
+
body
|
|
103
|
+
end
|
|
104
|
+
rescue Zlib::Error
|
|
105
|
+
body
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def rate_limit!
|
|
109
|
+
@mutex.synchronize do
|
|
110
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_request_time
|
|
111
|
+
sleep(@min_interval - elapsed) if elapsed < @min_interval
|
|
112
|
+
@last_request_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|