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,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edgar
4
+ # Fetches and parses SEC submissions JSON for a CIK entity.
5
+ class EntityData
6
+ attr_reader :cik, :raw
7
+
8
+ def initialize(cik:)
9
+ @cik = cik.to_i
10
+ @raw = fetch_data
11
+ end
12
+
13
+ def name
14
+ @raw["name"]
15
+ end
16
+
17
+ def tickers
18
+ @raw["tickers"] || []
19
+ end
20
+
21
+ def exchanges
22
+ @raw["exchanges"] || []
23
+ end
24
+
25
+ def sic
26
+ @raw["sic"]
27
+ end
28
+
29
+ def sic_description
30
+ @raw["sicDescription"]
31
+ end
32
+
33
+ def ein
34
+ @raw["ein"]
35
+ end
36
+
37
+ def entity_type
38
+ @raw["entityType"]
39
+ end
40
+
41
+ def fiscal_year_end
42
+ @raw["fiscalYearEnd"]
43
+ end
44
+
45
+ def phone
46
+ @raw["phone"]
47
+ end
48
+
49
+ def website
50
+ @raw["website"]
51
+ end
52
+
53
+ def description
54
+ @raw["description"]
55
+ end
56
+
57
+ def category
58
+ @raw["category"]
59
+ end
60
+
61
+ def state_of_incorporation
62
+ @raw["stateOfIncorporation"]
63
+ end
64
+
65
+ def company?
66
+ %w[operating company].include?(entity_type)
67
+ end
68
+
69
+ def individual?
70
+ entity_type == "individual"
71
+ end
72
+
73
+ def bdc?
74
+ entity_type == "bdc"
75
+ end
76
+
77
+ def not_found?
78
+ @raw.key?("error") || @raw.empty?
79
+ end
80
+
81
+ def mailing_address
82
+ addr = @raw.dig("addresses", "mailing")
83
+ Address.new(addr) if addr
84
+ end
85
+
86
+ def business_address
87
+ addr = @raw.dig("addresses", "business")
88
+ Address.new(addr) if addr
89
+ end
90
+
91
+ def recent_filings
92
+ @raw.dig("filings", "recent") || {}
93
+ end
94
+
95
+ def filing_count
96
+ (all_filings_hash["form"] || []).length
97
+ end
98
+
99
+ def all_filings
100
+ @all_filings ||= merge_all_filings
101
+ end
102
+
103
+ def filings_files
104
+ @raw.dig("filings", "files") || []
105
+ end
106
+
107
+ def to_h
108
+ @raw
109
+ end
110
+
111
+ private
112
+
113
+ def all_filings_hash
114
+ all_filings
115
+ end
116
+
117
+ def merge_all_filings
118
+ result = deep_copy_filing_hash(recent_filings)
119
+ filings_files.each do |file_entry|
120
+ page = fetch_filings_page(file_entry["name"])
121
+ next unless page
122
+
123
+ merge_filing_hash(result, page)
124
+ end
125
+ result
126
+ end
127
+
128
+ def fetch_filings_page(filename)
129
+ url = Config.submissions_file_url(@cik, filename)
130
+ data = Edgar.http_client.download_json(url)
131
+ # Paginated submission pages have the filing arrays at the top level
132
+ # (not wrapped under "filings.recent" like the main page).
133
+ page = data.dig("filings", "recent")
134
+ page || data
135
+ rescue StandardError => e
136
+ warn "Failed to fetch submissions page #{filename}: #{e.message}"
137
+ nil
138
+ end
139
+
140
+ def deep_copy_filing_hash(hash)
141
+ return {} if hash.nil? || hash.empty?
142
+
143
+ hash.each_with_object({}) do |(k, v), result|
144
+ result[k] = v.is_a?(Array) ? v.dup : v
145
+ end
146
+ end
147
+
148
+ def merge_filing_hash(target, source)
149
+ return target unless source.is_a?(Hash)
150
+
151
+ source.each do |key, values|
152
+ next unless values.is_a?(Array)
153
+
154
+ target[key] = if target[key].is_a?(Array)
155
+ target[key] + values
156
+ else
157
+ values.dup
158
+ end
159
+ end
160
+ target
161
+ end
162
+
163
+ def fetch_data
164
+ url = Config.submissions_url(@cik)
165
+ Edgar.http_client.download_json(url)
166
+ rescue StandardError => e
167
+ { "error" => e.message }
168
+ end
169
+ end
170
+
171
+ # Mailing or business address parsed from SEC entity data.
172
+ class Address
173
+ attr_reader :street1, :street2, :city, :state_or_country, :zipcode, :state_or_country_desc
174
+
175
+ def initialize(data)
176
+ @street1 = data["street1"]
177
+ @street2 = data["street2"]
178
+ @city = data["city"]
179
+ @state_or_country = data["stateOrCountry"]
180
+ @zipcode = data["zipCode"]
181
+ @state_or_country_desc = data["stateOrCountryDescription"]
182
+ end
183
+
184
+ def to_s
185
+ parts = [@street1, @street2, @city, @state_or_country, @zipcode].compact
186
+ parts.join(", ")
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edgar
4
+ # XBRL financial facts (US-GAAP, IFRS, DEI) for a CIK entity.
5
+ class EntityFacts
6
+ attr_reader :cik, :raw
7
+
8
+ def initialize(cik:)
9
+ @cik = cik.to_i
10
+ @raw = fetch_facts
11
+ end
12
+
13
+ def available?
14
+ @raw.key?("facts")
15
+ end
16
+
17
+ def us_gaap
18
+ @raw.dig("facts", "us-gaap") || {}
19
+ end
20
+
21
+ def ifrs_full
22
+ @raw.dig("facts", "ifrs-full") || {}
23
+ end
24
+
25
+ def dei
26
+ @raw.dig("facts", "dei") || {}
27
+ end
28
+
29
+ def concepts
30
+ us_gaap.keys + ifrs_full.keys
31
+ end
32
+
33
+ def concept(name)
34
+ us_gaap[name] || ifrs_full[name] || dei[name]
35
+ end
36
+
37
+ def concept_values(name, limit: nil, annual: nil)
38
+ c = concept(name)
39
+ return [] unless c
40
+
41
+ units = c["units"]
42
+ return [] unless units
43
+
44
+ values = units.values.flatten
45
+
46
+ if annual
47
+ has_fp = values.first&.key?("fp")
48
+ if has_fp
49
+ fy_values = values.select { |v| v["fp"] == "FY" }
50
+ values = fy_values unless fy_values.empty?
51
+ end
52
+ end
53
+
54
+ # Sort by filed date desc, then end date desc (matching Python edgartools'
55
+ # max by (filing_date, period_end) semantics)
56
+ values = values.sort_by { |v| [v["filed"] || v["end"] || "", v["end"] || ""] }.reverse
57
+
58
+ values = values.first(limit) if limit
59
+ values
60
+ end
61
+
62
+ def income_statement(periods: 4)
63
+ concepts = %w[Revenues Revenue CostOfGoodsAndServicesSold
64
+ GrossProfit OperatingExpenses
65
+ OperatingIncomeLoss
66
+ IncomeLossFromContinuingOperationsBeforeIncomeTaxExpenseBenefit
67
+ IncomeTaxExpenseBenefit
68
+ NetIncomeLoss]
69
+ extract_statement(concepts, periods)
70
+ end
71
+
72
+ def balance_sheet(periods: 4)
73
+ concepts = %w[Assets CurrentAssets CashAndCashEquivalentsAtCarryingValue
74
+ AccountsReceivableNetCurrent
75
+ PropertyPlantAndEquipmentNet
76
+ AssetsCurrent LiabilitiesCurrent
77
+ LongTermDebtCurrent Liabilities
78
+ StockholdersEquity]
79
+ extract_statement(concepts, periods)
80
+ end
81
+
82
+ def cashflow_statement(periods: 4)
83
+ concepts = %w[NetCashProvidedByUsedInOperatingActivities
84
+ NetCashProvidedByUsedInInvestingActivities
85
+ NetCashProvidedByUsedInFinancingActivities
86
+ CashAndCashEquivalentsPeriodIncreaseDecrease]
87
+ extract_statement(concepts, periods)
88
+ end
89
+
90
+ # Standardized financial accessors
91
+ # When +limit:+ is given, returns an array of raw fact hashes (legacy).
92
+ # Otherwise returns a single numeric value (matching Python edgartools).
93
+
94
+ def get_revenue(limit: nil, annual: true, period: nil)
95
+ # Matches Python edgartools EntityFacts concept variants
96
+ revenue_concepts = %w[RevenueFromContractWithCustomerExcludingAssessedTax
97
+ SalesRevenueNet
98
+ Revenues Revenue
99
+ TotalRevenues NetSales]
100
+ if limit
101
+ revenue_concepts.each do |name|
102
+ vals = concept_values(name, limit: limit, annual: annual)
103
+ return vals unless vals.empty?
104
+ end
105
+ else
106
+ revenue_concepts.each do |name|
107
+ val = _select_value(name, annual: annual, period: period)
108
+ return val if val
109
+ end
110
+ end
111
+ nil
112
+ end
113
+
114
+ def get_net_income(limit: nil, annual: true, period: nil)
115
+ # Matches Python edgartools EntityFacts concept variants
116
+ net_income_concepts = %w[NetIncomeLoss ProfitLoss NetIncome
117
+ NetEarnings NetIncomeLossAttributableToParent]
118
+ if limit
119
+ net_income_concepts.each do |name|
120
+ v = concept_values(name, limit: limit, annual: annual)
121
+ return v unless v.empty?
122
+ end
123
+ else
124
+ net_income_concepts.each do |name|
125
+ val = _select_value(name, annual: annual, period: period)
126
+ return val if val
127
+ end
128
+ end
129
+ nil
130
+ end
131
+
132
+ def get_operating_income(limit: nil, annual: true, period: nil)
133
+ # Matches Python edgartools EntityFacts concept variants
134
+ op_income_concepts = %w[OperatingIncomeLoss OperatingIncome
135
+ IncomeLossFromOperations OperatingProfit]
136
+ if limit
137
+ op_income_concepts.each do |name|
138
+ v = concept_values(name, limit: limit, annual: annual)
139
+ return v unless v.empty?
140
+ end
141
+ else
142
+ op_income_concepts.each do |name|
143
+ val = _select_value(name, annual: annual, period: period)
144
+ return val if val
145
+ end
146
+ end
147
+ nil
148
+ end
149
+
150
+ def get_total_assets(limit: nil, annual: true, period: nil)
151
+ # Matches Python edgartools EntityFacts concept variants
152
+ asset_concepts = %w[Assets TotalAssets AssetsCurrent]
153
+ if limit
154
+ asset_concepts.each do |name|
155
+ v = concept_values(name, limit: limit, annual: annual)
156
+ return v unless v.empty?
157
+ end
158
+ else
159
+ asset_concepts.each do |name|
160
+ val = _select_value(name, annual: annual, period: period)
161
+ return val if val
162
+ end
163
+ end
164
+ nil
165
+ end
166
+
167
+ def get_total_liabilities(limit: nil, annual: true, period: nil)
168
+ # Matches Python edgartools EntityFacts concept variants
169
+ liability_concepts = %w[Liabilities TotalLiabilities LiabilitiesAndStockholdersEquity]
170
+ if limit
171
+ liability_concepts.each do |name|
172
+ v = concept_values(name, limit: limit, annual: annual)
173
+ return v unless v.empty?
174
+ end
175
+ else
176
+ liability_concepts.each do |name|
177
+ val = _select_value(name, annual: annual, period: period)
178
+ return val if val
179
+ end
180
+ end
181
+ nil
182
+ end
183
+
184
+ def get_shareholders_equity(limit: nil, annual: true, period: nil)
185
+ # Matches Python edgartools EntityFacts concept variants
186
+ equity_concepts = %w[StockholdersEquity ShareholdersEquity
187
+ TotalEquity PartnersCapital MembersEquity]
188
+ if limit
189
+ equity_concepts.each do |name|
190
+ v = concept_values(name, limit: limit, annual: annual)
191
+ return v unless v.empty?
192
+ end
193
+ else
194
+ equity_concepts.each do |name|
195
+ val = _select_value(name, annual: annual, period: period)
196
+ return val if val
197
+ end
198
+ end
199
+ nil
200
+ end
201
+ alias get_stockholders_equity get_shareholders_equity
202
+
203
+ def get_gross_profit(limit: nil, annual: true, period: nil)
204
+ # Matches Python edgartools EntityFacts concept variants
205
+ gross_profit_concepts = %w[GrossProfit GrossMargin]
206
+ if limit
207
+ gross_profit_concepts.each do |name|
208
+ v = concept_values(name, limit: limit, annual: annual)
209
+ return v unless v.empty?
210
+ end
211
+ else
212
+ gross_profit_concepts.each do |name|
213
+ val = _select_value(name, annual: annual, period: period)
214
+ return val if val
215
+ end
216
+ end
217
+ nil
218
+ end
219
+
220
+ # Shares outstanding — tries DEI entity-level concept first,
221
+ # then falls back to US-GAAP balance sheet concept.
222
+ # Matches Python edgartools EntityFacts#shares_outstanding.
223
+ def shares_outstanding
224
+ # Try DEI entity-level concept first (most authoritative)
225
+ val = _select_value("EntityCommonStockSharesOutstanding", annual: false)
226
+ return val if val
227
+
228
+ # Fallback to US-GAAP balance sheet concept (covers multi-class cos like GOOG)
229
+ val = _select_value("CommonStockSharesOutstanding", annual: false)
230
+ return val if val
231
+
232
+ # Another fallback
233
+ _select_value("WeightedAverageNumberOfSharesOutstandingBasic", annual: false)
234
+ end
235
+
236
+ alias get_shares_outstanding shares_outstanding
237
+
238
+ # Trailing Twelve Months (TTM) calculations.
239
+ # Delegates to TTMCalculator which matches Python edgartools' TTMCalculator:
240
+ # 1. Classify facts by period duration (quarter: 70-120d, YTD_6M: 140-229d,
241
+ # YTD_9M: 230-329d, annual: 330-420d)
242
+ # 2. Derive Q2=YTD_6M-Q1, Q3=YTD_9M-YTD_6M, Q4=FY-YTD_9M
243
+ # 3. Take 4 most recent quarters and sum them.
244
+
245
+ def ttm(concept_name)
246
+ values = concept_values(concept_name, limit: nil, annual: false)
247
+ return nil if values.empty?
248
+
249
+ TTMCalculator.new(values).calculate
250
+ end
251
+
252
+ def ttm_revenue
253
+ result = ttm("RevenueFromContractWithCustomerExcludingAssessedTax")
254
+ return result if result
255
+
256
+ ttm("Revenues")
257
+ end
258
+
259
+ def ttm_net_income
260
+ ttm("NetIncomeLoss")
261
+ end
262
+
263
+ def ttm_operating_income
264
+ ttm("OperatingIncomeLoss")
265
+ end
266
+
267
+ # Select the best single value for a concept, matching Python edgartools'
268
+ # _get_standardized_concept_value semantics.
269
+ # When +annual+ is true (default), prefers FY (fiscal-year) facts.
270
+ # When +period+ is given (e.g. "2025-FY"), filters to that specific period.
271
+ # When multiple facts exist, selects by (filed desc, end desc) matching
272
+ # Python's max by (filing_date, period_end).
273
+ # Matches Python edgartools' fallback: if annual: true yields no results
274
+ # (e.g. balance sheet instant facts), falls back to the most recent fact.
275
+ def _select_value(concept_name, annual: true, period: nil)
276
+ c = concept(concept_name)
277
+ return nil unless c
278
+
279
+ values = c["units"]&.values&.flatten || []
280
+ return nil if values.empty?
281
+
282
+ if annual && period.nil?
283
+ has_fp = values.first&.key?("fp")
284
+ if has_fp
285
+ fy_values = values.select { |v| v["fp"] == "FY" }
286
+ values = fy_values unless fy_values.empty?
287
+ end
288
+ end
289
+
290
+ if period
291
+ fy_str, fp_str = period.split("-")
292
+ values = values.select { |v| v["fy"].to_s == fy_str && v["fp"] == fp_str }
293
+ end
294
+
295
+ return nil if values.empty?
296
+
297
+ # Select by (filed desc, end desc) matching Python's
298
+ # max by (filing_date, period_end)
299
+ best = values.max_by { |v| [v["filed"] || v["end"] || "", v["end"] || ""] }
300
+
301
+ best["val"]
302
+ end
303
+
304
+ def extract_statement(concept_names, periods)
305
+ result = {}
306
+ concept_names.each do |name|
307
+ values = concept_values(name, limit: periods)
308
+ result[name] = values if values.any?
309
+ end
310
+ result
311
+ end
312
+
313
+ private
314
+
315
+ def fetch_facts
316
+ url = Config.company_facts_url(@cik)
317
+ Edgar.http_client.download_json(url)
318
+ rescue StandardError => e
319
+ { "error" => e.message }
320
+ end
321
+ end
322
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edgar
4
+ # A single SEC filing: form type, date, text, attachments, and XBRL data.
5
+ class Filing
6
+ attr_reader :cik, :company, :form, :filing_date, :accession_number
7
+
8
+ def initialize(cik:, company:, form:, filing_date:, accession_number:)
9
+ @cik = cik.to_i
10
+ @company = company
11
+ @form = form
12
+ @filing_date = filing_date.is_a?(Date) ? filing_date : Date.parse(filing_date.to_s)
13
+ @accession_number = accession_number
14
+ @sgml = nil
15
+ @homepage_soup = nil
16
+ end
17
+
18
+ def all_ciks
19
+ [@cik]
20
+ end
21
+
22
+ def multi_entity?
23
+ all_ciks.length > 1
24
+ end
25
+
26
+ def filing_url
27
+ primary = homepage.primary_html_document
28
+ return primary.url if primary
29
+
30
+ text_url
31
+ end
32
+
33
+ def homepage_url
34
+ Config.filing_homepage_url(@cik, @accession_number)
35
+ end
36
+
37
+ def text_url
38
+ Config.filing_text_url(@cik, @accession_number)
39
+ end
40
+
41
+ def base_dir
42
+ no_dashes = @accession_number.delete("-")
43
+ "#{Config::SEC_ARCHIVE_URL}/data/#{@cik}/#{no_dashes}"
44
+ end
45
+
46
+ def header
47
+ @header ||= FilingHeader.new(sgml)
48
+ end
49
+
50
+ def period_of_report
51
+ header.period_of_report
52
+ end
53
+
54
+ def sgml
55
+ @sgml ||= fetch_sgml
56
+ end
57
+
58
+ def homepage
59
+ @homepage ||= fetch_homepage
60
+ end
61
+
62
+ def html
63
+ doc = homepage.primary_html_document
64
+ doc ? download_attachment(doc) : ""
65
+ end
66
+
67
+ def xml
68
+ doc = homepage.primary_xml_document
69
+ doc ? download_attachment(doc) : ""
70
+ end
71
+
72
+ def text
73
+ sgml_text = sgml_raw_text
74
+ return sgml_text if sgml_text
75
+
76
+ download_text_file
77
+ end
78
+
79
+ def attachments
80
+ homepage.attachments
81
+ end
82
+
83
+ def exhibits
84
+ attachments.exhibits
85
+ end
86
+
87
+ def reports
88
+ attachments.reports
89
+ end
90
+
91
+ def statements
92
+ attachments.statements
93
+ end
94
+
95
+ def xbrl
96
+ doc = homepage.xbrl_document
97
+ return nil unless doc
98
+
99
+ content = download_attachment(doc)
100
+ XBRL::XBRL.new(content)
101
+ end
102
+
103
+ def section_markers
104
+ @section_markers ||= Edgar::FilingSections.extract_sections(sgml)
105
+ end
106
+
107
+ def sections
108
+ Edgar::FilingSections.find_sections(sgml, form_type: @form)
109
+ end
110
+
111
+ def obj
112
+ DataObjects.for(self)
113
+ end
114
+
115
+ def accession_no
116
+ @accession_number
117
+ end
118
+
119
+ def markdown
120
+ text = sgml
121
+ return "" if text.empty?
122
+
123
+ text
124
+ .gsub(/<[^>]+>/, "")
125
+ .gsub(/&[a-zA-Z]+;/, " ")
126
+ .gsub(/\n{3,}/, "\n\n")
127
+ .strip
128
+ end
129
+
130
+ def to_s
131
+ "#<Filing #{@form} #{@company} #{@filing_date} #{@accession_number}>"
132
+ end
133
+
134
+ private
135
+
136
+ def fetch_sgml
137
+ text = download_text_file
138
+ return "" unless text
139
+
140
+ text
141
+ end
142
+
143
+ def sgml_raw_text
144
+ Edgar.http_client.download_text(text_url)
145
+ rescue StandardError
146
+ nil
147
+ end
148
+
149
+ def download_text_file
150
+ Edgar.http_client.download_text(text_url)
151
+ rescue StandardError => e
152
+ warn "Failed to download filing text: #{e.message}"
153
+ ""
154
+ end
155
+
156
+ def fetch_homepage
157
+ FilingHomepage.new(homepage_url)
158
+ rescue StandardError => e
159
+ warn "Failed to fetch filing homepage: #{e.message}"
160
+ FilingHomepage.empty
161
+ end
162
+
163
+ def download_attachment(attachment)
164
+ Edgar.http_client.download_text(attachment.url)
165
+ rescue StandardError => e
166
+ warn "Failed to download attachment: #{e.message}"
167
+ ""
168
+ end
169
+ end
170
+ end