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.
@@ -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