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,312 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Edgar
|
|
4
|
+
# Financial facts extracted from a filing's XBRL instance document,
|
|
5
|
+
# providing the same duck-typed interface as EntityFacts but scoped
|
|
6
|
+
# to the periods relevant to the filing (annual for 10-K, quarterly for 10-Q).
|
|
7
|
+
class FilingFacts
|
|
8
|
+
attr_reader :form_type, :contexts, :facts_map
|
|
9
|
+
|
|
10
|
+
def initialize(xbrl, form_type: nil)
|
|
11
|
+
@xbrl = xbrl
|
|
12
|
+
@form_type = form_type
|
|
13
|
+
@contexts = build_context_index
|
|
14
|
+
@facts_map = build_facts_map
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def available?
|
|
18
|
+
@facts_map.any?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def concept(name)
|
|
22
|
+
values = @facts_map[name]
|
|
23
|
+
return nil unless values&.any?
|
|
24
|
+
|
|
25
|
+
{ "label" => name, "units" => { "USD" => values } }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def concept_values(name, limit: nil, annual: nil)
|
|
29
|
+
c = concept(name)
|
|
30
|
+
return [] unless c
|
|
31
|
+
|
|
32
|
+
units = c["units"]
|
|
33
|
+
return [] unless units
|
|
34
|
+
|
|
35
|
+
values = units.values.flatten
|
|
36
|
+
|
|
37
|
+
if annual
|
|
38
|
+
has_fp = values.first&.key?("fp")
|
|
39
|
+
if has_fp
|
|
40
|
+
fy_values = values.select { |v| v["fp"] == "FY" }
|
|
41
|
+
values = fy_values unless fy_values.empty?
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Sort by filed date desc, then end date desc (matching Python edgartools'
|
|
46
|
+
# max by (filing_date, period_end) semantics)
|
|
47
|
+
values = values.sort_by { |v| [v["end"] || "", v["end"] || ""] }.reverse
|
|
48
|
+
|
|
49
|
+
values = values.first(limit) if limit
|
|
50
|
+
values
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def concepts
|
|
54
|
+
@facts_map.keys
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def us_gaap
|
|
58
|
+
@facts_map
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def ifrs_full
|
|
62
|
+
{}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def dei
|
|
66
|
+
{}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Standardized financial accessors
|
|
70
|
+
# When +limit:+ is given, returns an array of raw fact hashes (legacy).
|
|
71
|
+
# Otherwise returns a single numeric value (matching Python edgartools).
|
|
72
|
+
|
|
73
|
+
def get_revenue(limit: nil, annual: true, period: nil)
|
|
74
|
+
# Matches Python edgartools EntityFacts concept variants
|
|
75
|
+
revenue_concepts = %w[RevenueFromContractWithCustomerExcludingAssessedTax
|
|
76
|
+
SalesRevenueNet
|
|
77
|
+
Revenues Revenue
|
|
78
|
+
TotalRevenues NetSales]
|
|
79
|
+
if limit
|
|
80
|
+
revenue_concepts.each do |name|
|
|
81
|
+
vals = concept_values(name, limit: limit, annual: annual)
|
|
82
|
+
return vals unless vals.empty?
|
|
83
|
+
end
|
|
84
|
+
else
|
|
85
|
+
revenue_concepts.each do |name|
|
|
86
|
+
val = _select_value(name, annual: annual, period: period)
|
|
87
|
+
return val if val
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def get_net_income(limit: nil, annual: true, period: nil)
|
|
94
|
+
# Matches Python edgartools EntityFacts concept variants
|
|
95
|
+
net_income_concepts = %w[NetIncomeLoss ProfitLoss NetIncome
|
|
96
|
+
NetEarnings NetIncomeLossAttributableToParent]
|
|
97
|
+
if limit
|
|
98
|
+
net_income_concepts.each do |name|
|
|
99
|
+
v = concept_values(name, limit: limit, annual: annual)
|
|
100
|
+
return v unless v.empty?
|
|
101
|
+
end
|
|
102
|
+
else
|
|
103
|
+
net_income_concepts.each do |name|
|
|
104
|
+
val = _select_value(name, annual: annual, period: period)
|
|
105
|
+
return val if val
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def get_operating_income(limit: nil, annual: true, period: nil)
|
|
112
|
+
# Matches Python edgartools EntityFacts concept variants
|
|
113
|
+
op_income_concepts = %w[OperatingIncomeLoss OperatingIncome
|
|
114
|
+
IncomeLossFromOperations OperatingProfit]
|
|
115
|
+
if limit
|
|
116
|
+
op_income_concepts.each do |name|
|
|
117
|
+
v = concept_values(name, limit: limit, annual: annual)
|
|
118
|
+
return v unless v.empty?
|
|
119
|
+
end
|
|
120
|
+
else
|
|
121
|
+
op_income_concepts.each do |name|
|
|
122
|
+
val = _select_value(name, annual: annual, period: period)
|
|
123
|
+
return val if val
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def get_total_assets(limit: nil, annual: true, period: nil)
|
|
130
|
+
# Matches Python edgartools EntityFacts concept variants
|
|
131
|
+
asset_concepts = %w[Assets TotalAssets AssetsCurrent]
|
|
132
|
+
if limit
|
|
133
|
+
asset_concepts.each do |name|
|
|
134
|
+
v = concept_values(name, limit: limit, annual: annual)
|
|
135
|
+
return v unless v.empty?
|
|
136
|
+
end
|
|
137
|
+
else
|
|
138
|
+
asset_concepts.each do |name|
|
|
139
|
+
val = _select_value(name, annual: annual, period: period)
|
|
140
|
+
return val if val
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def get_total_liabilities(limit: nil, annual: true, period: nil)
|
|
147
|
+
# Matches Python edgartools EntityFacts concept variants
|
|
148
|
+
liability_concepts = %w[Liabilities TotalLiabilities LiabilitiesAndStockholdersEquity]
|
|
149
|
+
if limit
|
|
150
|
+
liability_concepts.each do |name|
|
|
151
|
+
v = concept_values(name, limit: limit, annual: annual)
|
|
152
|
+
return v unless v.empty?
|
|
153
|
+
end
|
|
154
|
+
else
|
|
155
|
+
liability_concepts.each do |name|
|
|
156
|
+
val = _select_value(name, annual: annual, period: period)
|
|
157
|
+
return val if val
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def get_shareholders_equity(limit: nil, annual: true, period: nil)
|
|
164
|
+
# Matches Python edgartools EntityFacts concept variants
|
|
165
|
+
equity_concepts = %w[StockholdersEquity ShareholdersEquity
|
|
166
|
+
TotalEquity PartnersCapital MembersEquity]
|
|
167
|
+
if limit
|
|
168
|
+
equity_concepts.each do |name|
|
|
169
|
+
v = concept_values(name, limit: limit, annual: annual)
|
|
170
|
+
return v unless v.empty?
|
|
171
|
+
end
|
|
172
|
+
else
|
|
173
|
+
equity_concepts.each do |name|
|
|
174
|
+
val = _select_value(name, annual: annual, period: period)
|
|
175
|
+
return val if val
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
alias get_stockholders_equity get_shareholders_equity
|
|
181
|
+
|
|
182
|
+
def get_gross_profit(limit: nil, annual: true, period: nil)
|
|
183
|
+
# Matches Python edgartools EntityFacts concept variants
|
|
184
|
+
gross_profit_concepts = %w[GrossProfit GrossMargin]
|
|
185
|
+
if limit
|
|
186
|
+
gross_profit_concepts.each do |name|
|
|
187
|
+
v = concept_values(name, limit: limit, annual: annual)
|
|
188
|
+
return v unless v.empty?
|
|
189
|
+
end
|
|
190
|
+
else
|
|
191
|
+
gross_profit_concepts.each do |name|
|
|
192
|
+
val = _select_value(name, annual: annual, period: period)
|
|
193
|
+
return val if val
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Select the best single value for a concept, matching Python edgartools'
|
|
200
|
+
# _get_standardized_concept_value semantics.
|
|
201
|
+
# When +annual+ is true (default), prefers FY (fiscal-year) facts.
|
|
202
|
+
# When +period+ is given (e.g. "2025-FY"), filters to that specific period.
|
|
203
|
+
# When multiple facts exist, selects by (end desc) matching Python's
|
|
204
|
+
# max by (period_end). FilingFacts doesn't have filed dates, so falls
|
|
205
|
+
# back to most recent end date.
|
|
206
|
+
def _select_value(concept_name, annual: true, period: nil)
|
|
207
|
+
c = concept(concept_name)
|
|
208
|
+
return nil unless c
|
|
209
|
+
|
|
210
|
+
values = c["units"]&.values&.flatten || []
|
|
211
|
+
return nil if values.empty?
|
|
212
|
+
|
|
213
|
+
if annual && period.nil?
|
|
214
|
+
has_fp = values.first&.key?("fp")
|
|
215
|
+
if has_fp
|
|
216
|
+
fy_values = values.select { |v| v["fp"] == "FY" }
|
|
217
|
+
values = fy_values unless fy_values.empty?
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
if period
|
|
222
|
+
fy_str, fp_str = period.split("-")
|
|
223
|
+
values = values.select { |v| v["fy"]&.to_s == fy_str && v["fp"] == fp_str }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
return nil if values.empty?
|
|
227
|
+
|
|
228
|
+
# Select by end date desc (FilingFacts lacks filed dates, so use end date)
|
|
229
|
+
best = values.max_by { |v| v["end"] || v["start"] || "" }
|
|
230
|
+
|
|
231
|
+
best["val"]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private
|
|
235
|
+
|
|
236
|
+
def build_context_index
|
|
237
|
+
@xbrl.context_refs.to_h { |ctx| [ctx[:id], ctx] }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def build_facts_map
|
|
241
|
+
map = {}
|
|
242
|
+
@xbrl.financial_concepts.each do |fact|
|
|
243
|
+
name = fact[:name]
|
|
244
|
+
ctx = @contexts[fact[:context_ref]]
|
|
245
|
+
next unless ctx
|
|
246
|
+
|
|
247
|
+
next unless period_matches_form?(ctx)
|
|
248
|
+
|
|
249
|
+
# Skip segmented (dimension-level) facts — prefer entity-level
|
|
250
|
+
# (no segment dimensions) to avoid picking product/geographic
|
|
251
|
+
# segment values over total-entity values.
|
|
252
|
+
next if ctx[:segmented]
|
|
253
|
+
|
|
254
|
+
end_date = ctx[:end_date] || ctx[:instant]
|
|
255
|
+
start_date = ctx[:start_date]
|
|
256
|
+
|
|
257
|
+
fp = nil
|
|
258
|
+
fy = end_date ? Date.parse(end_date).year : nil
|
|
259
|
+
if start_date && end_date
|
|
260
|
+
duration = days_between(start_date, end_date)
|
|
261
|
+
fp = "FY" if duration > 300
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
value = {
|
|
265
|
+
"val" => fact[:value].to_f,
|
|
266
|
+
"end" => end_date,
|
|
267
|
+
"start" => start_date,
|
|
268
|
+
"fp" => fp,
|
|
269
|
+
"fy" => fy,
|
|
270
|
+
"unit" => extract_unit(fact[:unit_ref])
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
map[name] ||= []
|
|
274
|
+
map[name] << value
|
|
275
|
+
end
|
|
276
|
+
map
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def period_matches_form?(context)
|
|
280
|
+
return true unless @form_type
|
|
281
|
+
|
|
282
|
+
if @form_type.start_with?("10-K")
|
|
283
|
+
if context[:start_date] && context[:end_date]
|
|
284
|
+
duration = days_between(context[:start_date], context[:end_date])
|
|
285
|
+
return false if duration < 300 && duration.positive?
|
|
286
|
+
end
|
|
287
|
+
elsif @form_type.start_with?("10-Q")
|
|
288
|
+
if context[:start_date] && context[:end_date]
|
|
289
|
+
duration = days_between(context[:start_date], context[:end_date])
|
|
290
|
+
return false if duration > 200
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
true
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def days_between(start_str, end_str)
|
|
297
|
+
(Date.parse(end_str) - Date.parse(start_str)).to_i
|
|
298
|
+
rescue ArgumentError
|
|
299
|
+
0
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def extract_unit(unit_ref)
|
|
303
|
+
return nil unless unit_ref
|
|
304
|
+
|
|
305
|
+
unit = @xbrl.units.find { |u| u[:id] == unit_ref }
|
|
306
|
+
return nil unless unit
|
|
307
|
+
|
|
308
|
+
measure = unit[:measure]
|
|
309
|
+
measure&.sub("iso4217:", "")
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Edgar
|
|
4
|
+
# Parses the <SEC-HEADER> section of an SEC filing text.
|
|
5
|
+
class FilingHeader
|
|
6
|
+
attr_reader :raw_text, :fields
|
|
7
|
+
|
|
8
|
+
def initialize(sgml_text)
|
|
9
|
+
@raw_text = ""
|
|
10
|
+
@fields = {}
|
|
11
|
+
|
|
12
|
+
parse_header(sgml_text.to_s)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def filers
|
|
16
|
+
@fields["FILER"] || []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def period_of_report
|
|
20
|
+
raw = @fields["CONFORMED PERIOD OF REPORT"]&.first
|
|
21
|
+
return nil unless raw
|
|
22
|
+
|
|
23
|
+
# Format YYYYMMDD as YYYY-MM-DD (ISO 8601)
|
|
24
|
+
if raw.match?(/\A\d{8}\z/)
|
|
25
|
+
"#{raw[0..3]}-#{raw[4..5]}-#{raw[6..7]}"
|
|
26
|
+
else
|
|
27
|
+
raw
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def filer_cik
|
|
32
|
+
filers.first["COMPANY DATA CENTRAL INDEX KEY"] || filers.first["CENTRAL INDEX KEY"] if filers.any?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def filer_company
|
|
36
|
+
filers.first["COMPANY DATA COMPANY CONFORMED NAME"] || filers.first["COMPANY CONFORMED NAME"] if filers.any?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def form_type
|
|
40
|
+
@fields["FORM TYPE"]&.first || @fields["CONFORMED SUBMISSION TYPE"]&.first
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def accession_number
|
|
44
|
+
@fields["ACCESSION NUMBER"]&.first
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_s
|
|
48
|
+
"#<FilingHeader #{@fields.keys.length} keys>"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def parse_header(text)
|
|
54
|
+
header_match = text.match(%r{<SEC-HEADER>(.*?)</SEC-HEADER>}m)
|
|
55
|
+
return unless header_match
|
|
56
|
+
|
|
57
|
+
@raw_text = header_match[1]
|
|
58
|
+
|
|
59
|
+
filer_data = nil
|
|
60
|
+
filer_section = nil
|
|
61
|
+
|
|
62
|
+
@raw_text.each_line do |line|
|
|
63
|
+
strip = line.strip
|
|
64
|
+
next if strip.empty?
|
|
65
|
+
|
|
66
|
+
indent = line[/\A[ \t]*/].length
|
|
67
|
+
tab_count = line[/\A\t*/].length
|
|
68
|
+
|
|
69
|
+
if indent.zero?
|
|
70
|
+
filer_data, filer_section = finish_filer(filer_data, filer_section)
|
|
71
|
+
filer_data, filer_section = parse_top_level(line, strip, filer_data)
|
|
72
|
+
elsif tab_count >= 1 || indent >= 1
|
|
73
|
+
filer_data, filer_section = parse_indented(strip, filer_data, filer_section)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
return unless filer_data&.any?
|
|
78
|
+
|
|
79
|
+
@fields["FILER"] ||= []
|
|
80
|
+
@fields["FILER"] << filer_data
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def finish_filer(filer_data, _filer_section)
|
|
84
|
+
return [nil, nil] unless filer_data
|
|
85
|
+
|
|
86
|
+
@fields["FILER"] ||= []
|
|
87
|
+
@fields["FILER"] << filer_data
|
|
88
|
+
[nil, nil]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def parse_top_level(_line, strip, filer_data)
|
|
92
|
+
if ["FILER:", "FILER"].include?(strip)
|
|
93
|
+
filer_data = {}
|
|
94
|
+
filer_section = nil
|
|
95
|
+
return [filer_data, filer_section] if strip.end_with?(":")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
return [filer_data, nil] unless strip.include?(":")
|
|
99
|
+
|
|
100
|
+
k, v = strip.split(":", 2).map(&:strip)
|
|
101
|
+
k = k.strip.gsub(/\s+/, " ") if k
|
|
102
|
+
if k && !k.empty?
|
|
103
|
+
@fields[k] ||= []
|
|
104
|
+
@fields[k] << v if v && !v.empty?
|
|
105
|
+
end
|
|
106
|
+
[filer_data, nil]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def parse_indented(strip, filer_data, filer_section)
|
|
110
|
+
if filer_data
|
|
111
|
+
if strip.end_with?(":") && !strip.start_with?("-")
|
|
112
|
+
filer_section = strip.sub(/:+\z/, "").strip
|
|
113
|
+
elsif strip.include?(":")
|
|
114
|
+
k, v = strip.split(":", 2).map(&:strip)
|
|
115
|
+
key = filer_section ? "#{filer_section} #{k}" : k
|
|
116
|
+
filer_data[key] = v if k && v && !k.empty?
|
|
117
|
+
end
|
|
118
|
+
elsif strip.include?(":") && strip.end_with?(":")
|
|
119
|
+
filer_section = strip.sub(/:+\z/, "").strip
|
|
120
|
+
end
|
|
121
|
+
[filer_data, filer_section]
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module Edgar
|
|
6
|
+
# Parses the SEC filing HTML index page listing document attachments.
|
|
7
|
+
class FilingHomepage
|
|
8
|
+
attr_reader :url, :soup, :attachments
|
|
9
|
+
|
|
10
|
+
def initialize(url, soup = nil)
|
|
11
|
+
@url = url
|
|
12
|
+
@soup = soup || fetch_soup
|
|
13
|
+
@attachments = parse_attachments
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.empty
|
|
17
|
+
new("", Nokogiri::HTML(""))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def documents
|
|
21
|
+
@attachments.select { |a| a.html? || a.text? }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def datafiles
|
|
25
|
+
@attachments.select(&:xml?)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def primary_documents
|
|
29
|
+
@attachments.primary_documents
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def primary_html_document
|
|
33
|
+
@attachments.primary_html_document
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def primary_xml_document
|
|
37
|
+
@attachments.primary_xml_document
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def xbrl_document
|
|
41
|
+
@attachments.xbrl_document
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def period_of_report
|
|
45
|
+
text = @soup.text
|
|
46
|
+
match = text.match(/Period of Report\s*:\s*(\S+)/i)
|
|
47
|
+
match ? match[1] : nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def filers
|
|
51
|
+
@soup.css("div.filer, .companyInfo, .mailer").map do |el|
|
|
52
|
+
{ name: el.text.strip }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def to_s
|
|
57
|
+
"#<FilingHomepage #{@url}>"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def fetch_soup
|
|
63
|
+
return Nokogiri::HTML("") if @url.empty?
|
|
64
|
+
|
|
65
|
+
html = Edgar.http_client.download_text(@url)
|
|
66
|
+
Nokogiri::HTML(html)
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
warn "Failed to fetch homepage: #{e.message}"
|
|
69
|
+
Nokogiri::HTML("")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parse_attachments
|
|
73
|
+
result = []
|
|
74
|
+
rows = @soup.css("table.tableFile tr")
|
|
75
|
+
|
|
76
|
+
rows.each do |row|
|
|
77
|
+
cells = row.css("td")
|
|
78
|
+
next if cells.length < 4 || cells[0]["class"] == "header"
|
|
79
|
+
|
|
80
|
+
seq_text = cells[0]&.text&.strip
|
|
81
|
+
next if seq_text.nil? || seq_text.empty?
|
|
82
|
+
next if seq_text == "Seq"
|
|
83
|
+
|
|
84
|
+
link = row.at_css("a")
|
|
85
|
+
next unless link
|
|
86
|
+
|
|
87
|
+
href = link["href"].to_s.strip
|
|
88
|
+
next if href.empty?
|
|
89
|
+
|
|
90
|
+
full_url = if href.start_with?("/")
|
|
91
|
+
"https://www.sec.gov#{href}"
|
|
92
|
+
elsif href.start_with?("http")
|
|
93
|
+
href
|
|
94
|
+
else
|
|
95
|
+
URI.join(@url, href).to_s
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
seq = seq_text.to_i
|
|
99
|
+
description = cells[1]&.text&.strip
|
|
100
|
+
doc_name = link.text.strip
|
|
101
|
+
doc_type = cells[3]&.text&.strip
|
|
102
|
+
size_text = cells[4]&.text&.strip
|
|
103
|
+
size = size_text&.gsub(/[^0-9.]/, "")
|
|
104
|
+
|
|
105
|
+
result << Attachment.new(
|
|
106
|
+
sequence_number: seq,
|
|
107
|
+
description: description,
|
|
108
|
+
document: doc_name.empty? ? href.split("/").last : doc_name,
|
|
109
|
+
document_type: doc_type,
|
|
110
|
+
size: size,
|
|
111
|
+
url: full_url
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
Attachments.new(result)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Edgar
|
|
4
|
+
# Parses SEC quarterly EDGAR index .gz files into index entries.
|
|
5
|
+
class FilingIndex
|
|
6
|
+
IndexEntry = Struct.new(:form, :company, :cik, :filing_date, :accession_number, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
attr_reader :year, :quarter, :type
|
|
9
|
+
|
|
10
|
+
def initialize(year, quarter, type: "company")
|
|
11
|
+
@year = year.to_i
|
|
12
|
+
@quarter = quarter.to_i
|
|
13
|
+
@type = type
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def entries
|
|
17
|
+
@entries ||= parse_index
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.for(year, quarter, type: "company")
|
|
21
|
+
new(year, quarter, type: type).entries
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
INDEX_LINE_RE = /\A(.+?)\s{2,}(\S{1,20})\s{2,}(\d{1,10})\s{2,}(\d{4}-\d{2}-\d{2})\s{2,}(\S+)\s*\z/
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def parse_index
|
|
29
|
+
url = Config.full_index_url(@year, @quarter, @type)
|
|
30
|
+
content = download_and_decompress(url)
|
|
31
|
+
parse_fixed_width(content)
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
warn "Failed to fetch index for #{@year}/Q#{@quarter} (#{@type}): #{e.message}"
|
|
34
|
+
[]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def download_and_decompress(url)
|
|
38
|
+
Edgar.http_client.download_text(url)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def parse_fixed_width(content)
|
|
42
|
+
text = content.encode("UTF-8", invalid: :replace, undef: :replace)
|
|
43
|
+
lines = text.lines
|
|
44
|
+
entries = []
|
|
45
|
+
started = false
|
|
46
|
+
|
|
47
|
+
lines.each do |line|
|
|
48
|
+
if line.include?("----")
|
|
49
|
+
started = true
|
|
50
|
+
next
|
|
51
|
+
end
|
|
52
|
+
next unless started
|
|
53
|
+
|
|
54
|
+
row = parse_index_row(line)
|
|
55
|
+
next unless row
|
|
56
|
+
|
|
57
|
+
entries << IndexEntry.new(row)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
entries
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def parse_index_row(line)
|
|
64
|
+
line = line.sub(/\n\z/, "")
|
|
65
|
+
return nil if line.strip.empty?
|
|
66
|
+
|
|
67
|
+
m = line.match(INDEX_LINE_RE)
|
|
68
|
+
return nil unless m
|
|
69
|
+
|
|
70
|
+
company = m[1].strip
|
|
71
|
+
form = m[2].strip
|
|
72
|
+
cik = m[3].to_i
|
|
73
|
+
filing_date = m[4].strip
|
|
74
|
+
fname = m[5].strip
|
|
75
|
+
|
|
76
|
+
accession_number = File.basename(fname, ".txt")
|
|
77
|
+
|
|
78
|
+
{
|
|
79
|
+
company: company,
|
|
80
|
+
form: form,
|
|
81
|
+
cik: cik,
|
|
82
|
+
filing_date: filing_date,
|
|
83
|
+
accession_number: accession_number
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Edgar
|
|
4
|
+
# Extracts section markers (Item 1, Item 1A, etc.) from filing text.
|
|
5
|
+
class FilingSections
|
|
6
|
+
SECTION_MARKERS = {
|
|
7
|
+
"1" => "Business",
|
|
8
|
+
"1A" => "Risk Factors",
|
|
9
|
+
"1B" => "Unresolved Staff Comments",
|
|
10
|
+
"2" => "Properties",
|
|
11
|
+
"3" => "Legal Proceedings",
|
|
12
|
+
"4" => "Mine Safety Disclosures",
|
|
13
|
+
"5" => "Market for Registrant's Common Equity",
|
|
14
|
+
"6" => "Selected Financial Data",
|
|
15
|
+
"7" => "Management's Discussion and Analysis",
|
|
16
|
+
"7A" => "Quantitative and Qualitative Disclosures About Market Risk",
|
|
17
|
+
"8" => "Financial Statements and Supplementary Data",
|
|
18
|
+
"9" => "Changes in and Disagreements with Accountants",
|
|
19
|
+
"9A" => "Controls and Procedures",
|
|
20
|
+
"9B" => "Other Information",
|
|
21
|
+
"10" => "Directors and Executive Officers",
|
|
22
|
+
"11" => "Executive Compensation",
|
|
23
|
+
"12" => "Security Ownership of Certain Beneficial Owners",
|
|
24
|
+
"13" => "Certain Relationships and Related Transactions",
|
|
25
|
+
"14" => "Principal Accountant Fees and Services",
|
|
26
|
+
"15" => "Exhibits and Financial Statement Schedules"
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
def self.extract_sections(sgml_text)
|
|
30
|
+
markers = {}
|
|
31
|
+
sgml_text.to_s.each_line do |line|
|
|
32
|
+
next unless line.strip =~ /^Item\s+(\d+[A-Za-z]?)\.?\s+(.+)/i
|
|
33
|
+
|
|
34
|
+
markers[::Regexp.last_match(1)] = ::Regexp.last_match(2).strip
|
|
35
|
+
end
|
|
36
|
+
markers
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.find_sections(sgml_text, **_unused)
|
|
40
|
+
extract_sections(sgml_text)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|