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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c84e8a66ea352689f9a15dbf08929fa4e9756179c39584bb4c2b2a0171273fe3
4
+ data.tar.gz: 8bed90b9eecebf10d7128169481bbc15b992eb809557aedccaece3dfdd6536bc
5
+ SHA512:
6
+ metadata.gz: 05dc039c7a2a9be8793772ec81994ea63cf58ec9f68563e055f3fdea7636e9e4466be9af2a9bc1ec83499fc862dbf2acde7c1bc2c839eeac1ea71543bc5c8ed7
7
+ data.tar.gz: c5534a89eb6b87fe1a9b70bb94b6c258bb10f449442428d297df9155be3937ae270f354bf85b1e7eac89a7cefbc7819f0e4b00fbd41b6743ed469475ea6006a6
data/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # Edgar
2
+
3
+ A Ruby library for accessing SEC EDGAR filings. Port of the Python [edgartools](https://github.com/dgunning/edgartools) library.
4
+
5
+ ## Installation
6
+
7
+ ```ruby
8
+ gem "ruby-edgar", git: "https://github.com/tian-im/edgar"
9
+ ```
10
+
11
+ Or build locally:
12
+
13
+ ```
14
+ gem build ruby-edgar.gemspec
15
+ gem install ruby-edgar-0.1.0.gem
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ### Company Lookup
21
+
22
+ ```ruby
23
+ require "edgar"
24
+
25
+ # By ticker
26
+ aapl = Edgar.Company("AAPL")
27
+ aapl.cik # => 320193
28
+
29
+ # By CIK
30
+ company = Edgar::Company.new(cik: 320193)
31
+ ```
32
+
33
+ ### Entity Data
34
+
35
+ ```ruby
36
+ data = aapl.data
37
+ data.name # => "Apple Inc."
38
+ data.tickers # => ["AAPL"]
39
+ data.exchanges # => ["Nasdaq"]
40
+ data.sic # => 3571
41
+ data.category # => "Large accelerated filer"
42
+ data.business_address # => Address
43
+ ```
44
+
45
+ ### Financial Facts
46
+
47
+ ```ruby
48
+ facts = aapl.facts
49
+ facts.concept_values("Revenues", limit: 1)
50
+ facts.concept_values("NetIncomeLoss", limit: 1)
51
+ facts.list_concepts(search: "Revenue", limit: 10)
52
+ ```
53
+
54
+ ### Search Filings by Quarter
55
+
56
+ ```ruby
57
+ filings = Edgar::Filings.from_quarter(2024, 4)
58
+ aapl_filings = filings.filter(cik: 320193)
59
+ aapl_filings.latest(5).each { |f| puts "#{f.form} #{f.filing_date}" }
60
+ ```
61
+
62
+ ### Access a Filing
63
+
64
+ ```ruby
65
+ filing = aapl_filings.first
66
+ filing.form # => "10-K"
67
+ filing.filing_date # => Date
68
+ filing.html # => Primary HTML document
69
+ filing.text # => Full submission text
70
+ filing.header.period_of_report # => "20240928"
71
+ filing.header.filer_company # => "Apple Inc."
72
+ ```
73
+
74
+ ### Attachments
75
+
76
+ ```ruby
77
+ filing.attachments.each do |a|
78
+ puts "#{a.document} (#{a.document_type})"
79
+ end
80
+
81
+ filing.exhibits # Exhibit attachments
82
+ filing.datafiles # XML data files
83
+ ```
84
+
85
+ ### Data Objects
86
+
87
+ ```ruby
88
+ obj = Edgar::DataObjects.for(filing)
89
+ # Returns TenK, TenQ, Form4, etc. based on form type
90
+ obj.form_type
91
+ obj.company_name
92
+ ```
93
+
94
+ ### XBRL
95
+
96
+ ```ruby
97
+ xbrl = filing.xbrl
98
+ xbrl.facts # Array of fact hashes
99
+ xbrl.context_refs # Reporting contexts
100
+ xbrl.financial_concepts # US-GAAP concepts
101
+ ```
102
+
103
+ ## Configuration
104
+
105
+ Set the `EDGAR_IDENTITY` environment variable:
106
+
107
+ ```
108
+ export EDGAR_IDENTITY="Your Name your.email@example.com"
109
+ ```
110
+
111
+ ## Development
112
+
113
+ ```
114
+ bundle install
115
+ ruby -e 'require "bundler/setup"; require "edgar"'
116
+ ```
117
+
118
+ ## License
119
+
120
+ MIT
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edgar
4
+ # Represents a single file attachment within an SEC filing.
5
+ class Attachment
6
+ EXHIBIT_DESCRIPTIONS = {
7
+ "10-K" => "Annual Report",
8
+ "10-Q" => "Quarterly Report",
9
+ "8-K" => "Current Report",
10
+ "EX-10" => "Material Contract",
11
+ "EX-21" => "Subsidiaries",
12
+ "EX-23" => "Consent of Expert",
13
+ "EX-31" => "Certification",
14
+ "EX-32" => "Certification"
15
+ }.freeze
16
+
17
+ attr_reader :sequence_number, :description, :document, :ixbrl, :path,
18
+ :document_type, :size, :sgml_document, :purpose, :url
19
+
20
+ def initialize(sequence_number:, description: nil, document: nil, ixbrl: false,
21
+ path: nil, document_type: nil, size: nil, sgml_document: nil,
22
+ purpose: nil, url: nil)
23
+ @sequence_number = sequence_number
24
+ @description = description
25
+ @document = document
26
+ @ixbrl = ixbrl
27
+ @path = path
28
+ @document_type = document_type
29
+ @size = size
30
+ @sgml_document = sgml_document
31
+ @purpose = purpose
32
+ @url = url
33
+ end
34
+
35
+ def display_description
36
+ EXHIBIT_DESCRIPTIONS[@document_type] || @description
37
+ end
38
+
39
+ def extension
40
+ return "" unless @document
41
+
42
+ File.extname(@document).downcase
43
+ end
44
+
45
+ def text?
46
+ extension == ".txt" || document_type&.include?("TEXT")
47
+ end
48
+
49
+ def xml?
50
+ [".xml", ".xsl"].include?(extension)
51
+ end
52
+
53
+ def html?
54
+ [".htm", ".html"].include?(extension)
55
+ end
56
+
57
+ def binary?
58
+ !text? && !xml? && !html?
59
+ end
60
+
61
+ def empty?
62
+ @size.nil? || @size.to_i.zero?
63
+ end
64
+
65
+ def to_s
66
+ "#<Attachment seq=#{@sequence_number} type=#{@document_type} desc='#{@description}'>"
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edgar
4
+ # Enumerable collection of Attachment objects for a filing.
5
+ class Attachments
6
+ include Enumerable
7
+
8
+ attr_reader :attachments
9
+
10
+ def initialize(attachments = [])
11
+ @attachments = attachments
12
+ end
13
+
14
+ def each(&block)
15
+ @attachments.each(&block)
16
+ end
17
+
18
+ def [](index)
19
+ @attachments[index]
20
+ end
21
+
22
+ def length
23
+ @attachments.length
24
+ end
25
+
26
+ def exhibits
27
+ select { |a| a.purpose&.downcase&.include?("exhibit") }
28
+ end
29
+
30
+ def reports
31
+ select { |a| a.purpose&.downcase&.include?("report") }
32
+ end
33
+
34
+ def statements
35
+ select { |a| a.purpose&.downcase&.include?("statement") }
36
+ end
37
+
38
+ def primary_documents
39
+ sorted = sort_by(&:sequence_number)
40
+ sorted.select { |a| a.sequence_number == sorted.first&.sequence_number }
41
+ end
42
+
43
+ def primary_html_document
44
+ sorted = sort_by(&:sequence_number)
45
+ sorted.find(&:html?)
46
+ end
47
+
48
+ def primary_xml_document
49
+ find(&:xml?)
50
+ end
51
+
52
+ def xbrl_document
53
+ # Standard XBRL instance (EX-101.INS) or iXBRL-tagged document
54
+ found = find { |a| a.document_type == "EX-101.INS" || a.ixbrl }
55
+ return found if found
56
+
57
+ # Fallback: look for _htm.xml extracted XBRL instance (common in modern
58
+ # iXBRL filings where the instance is embedded in the HTML and an XML
59
+ # copy is provided as a datafile)
60
+ find { |a| a.document&.end_with?("_htm.xml") }
61
+ end
62
+
63
+ def datafiles
64
+ select(&:xml?)
65
+ end
66
+
67
+ def documents
68
+ select { |a| a.html? || a.text? }
69
+ end
70
+
71
+ def to_s
72
+ "#<Attachments count=#{@attachments.length}>"
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edgar
4
+ # A company identified by ticker symbol, extending Entity with SEC lookup.
5
+ class Company < Entity
6
+ attr_reader :ticker
7
+
8
+ def initialize(cik: nil, ticker: nil)
9
+ if ticker
10
+ @ticker = ticker.upcase
11
+ cik ||= Company.lookup_cik(@ticker)
12
+ end
13
+
14
+ super(cik: cik)
15
+ end
16
+
17
+ def tickers
18
+ data.tickers
19
+ end
20
+
21
+ def primary_ticker
22
+ tickers.first
23
+ end
24
+
25
+ def exchanges
26
+ data.exchanges
27
+ end
28
+
29
+ def sic
30
+ data.sic
31
+ end
32
+
33
+ def industry
34
+ data.sic_description
35
+ end
36
+
37
+ def fiscal_year_end
38
+ data.fiscal_year_end
39
+ end
40
+
41
+ def website
42
+ data.website
43
+ end
44
+
45
+ def phone
46
+ data.phone
47
+ end
48
+
49
+ def state_of_incorporation
50
+ data.state_of_incorporation
51
+ end
52
+
53
+ def description
54
+ data.description
55
+ end
56
+
57
+ def financials
58
+ @financials ||= begin
59
+ filing = find_latest_filing_by_form("10-K") ||
60
+ find_latest_filing_by_form("20-F") ||
61
+ find_latest_filing_by_form("40-F")
62
+ if filing
63
+ Financials.extract(filing) || Financials.new(cik: @cik)
64
+ else
65
+ Financials.new(cik: @cik)
66
+ end
67
+ end
68
+ end
69
+
70
+ def facts
71
+ @facts ||= EntityFacts.new(cik: @cik)
72
+ end
73
+
74
+ def latest_tenk
75
+ find_latest_filing_by_form("10-K")
76
+ end
77
+
78
+ def latest_tenq
79
+ find_latest_filing_by_form("10-Q")
80
+ end
81
+
82
+ def annual_financials
83
+ filing = latest_tenk
84
+ return Financials.extract(filing) if filing
85
+
86
+ %w[20-F 40-F].each do |form|
87
+ f = find_latest_filing_by_form(form)
88
+ return Financials.extract(f) if f
89
+ end
90
+ nil
91
+ end
92
+
93
+ def quarterly_financials
94
+ filing = latest_tenq
95
+ return Financials.extract(filing) if filing
96
+
97
+ filing = find_latest_filing_by_form("6-K")
98
+ return Financials.extract(filing) if filing
99
+
100
+ nil
101
+ end
102
+
103
+ def business_category
104
+ sic_str = sic.to_s
105
+ return "Bank" if %w[6021 6022 6035 6036].include?(sic_str)
106
+ return "Insurance Company" if %w[6311 6321 6331].include?(sic_str)
107
+ return "REIT" if sic_str == "6798"
108
+
109
+ et = data.entity_type.to_s.downcase
110
+ return "Investment Manager" if et.include?("investment")
111
+ return "Operating Company" if et == "operating"
112
+
113
+ "Operating Company"
114
+ end
115
+
116
+ def fund?
117
+ ["ETF", "Mutual Fund", "Closed-End Fund"].include?(business_category)
118
+ end
119
+
120
+ def operating_company?
121
+ business_category == "Operating Company"
122
+ end
123
+
124
+ def financial_institution?
125
+ ["Bank", "Insurance Company", "Investment Manager", "BDC"].include?(business_category)
126
+ end
127
+
128
+ def foreign?
129
+ %w[Foreign Canadian].include?(filer_type)
130
+ end
131
+
132
+ def filer_type
133
+ return "Domestic" if state_of_incorporation == "CA"
134
+ return "Foreign" unless %w[CA DE FL IL MA MD NJ NY NV OH PA TX VA WA].include?(state_of_incorporation)
135
+
136
+ "Domestic"
137
+ end
138
+
139
+ def to_s
140
+ ticker_str = @ticker || tickers.first
141
+ "#<Company #{ticker_str} (#{@cik})>"
142
+ end
143
+
144
+ def self.lookup_cik(ticker)
145
+ return @ticker_cache[ticker.upcase] if @ticker_cache&.key?(ticker.upcase)
146
+
147
+ data = load_company_data
148
+ mapping = {}
149
+
150
+ data.each do |entry|
151
+ t = entry[:ticker]
152
+ mapping[t] = entry[:cik]
153
+ end
154
+
155
+ cik = mapping[ticker.upcase]
156
+ (@ticker_cache ||= {})[ticker.upcase] = cik if cik
157
+ cik
158
+ end
159
+
160
+ def self.search(query, limit: 10)
161
+ return [] if query.nil? || query.strip.empty?
162
+
163
+ data = load_company_data
164
+ q = query.strip.downcase
165
+
166
+ matches = data.select do |entry|
167
+ entry[:ticker].downcase.include?(q) ||
168
+ entry[:title].downcase.include?(q)
169
+ end
170
+
171
+ matches.first(limit)
172
+ end
173
+
174
+ def self.load_company_data
175
+ return @company_data_cache if @company_data_cache
176
+
177
+ @company_data_cache = []
178
+ url = Config.tickers_url
179
+ raw = Edgar.http_client.download_json(url)
180
+
181
+ raw.each_value do |entry|
182
+ @company_data_cache << {
183
+ cik: entry["cik_str"].to_i,
184
+ ticker: entry["ticker"],
185
+ title: entry["title"]
186
+ }
187
+ end
188
+
189
+ @company_data_cache
190
+ end
191
+
192
+ def self.clear_search_cache!
193
+ @company_data_cache = nil
194
+ end
195
+
196
+ private_class_method :load_company_data
197
+
198
+ private
199
+
200
+ def find_latest_filing_by_form(form)
201
+ recent = data.recent_filings
202
+ forms = recent["form"] || []
203
+ dates = recent["filingDate"] || []
204
+ accs = recent["accessionNumber"] || []
205
+ idx = forms.index(form)
206
+ return nil unless idx
207
+
208
+ Filing.new(
209
+ cik: @cik,
210
+ company: display_name,
211
+ form: form,
212
+ filing_date: dates[idx],
213
+ accession_number: accs[idx]
214
+ )
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edgar
4
+ # SEC endpoint URLs, rate limit constants, and URL builder methods.
5
+ module Config
6
+ SEC_BASE_URL = "https://www.sec.gov"
7
+ SEC_DATA_URL = "https://data.sec.gov"
8
+ SEC_ARCHIVE_URL = "#{SEC_BASE_URL}/Archives/edgar".freeze
9
+
10
+ EDGAR_RATE_LIMIT_PER_SEC = 9
11
+ EDGAR_IDENTITY = ENV.fetch("EDGAR_IDENTITY", "SampleCompanyName sample.email@example.com")
12
+
13
+ DEFAULT_TIMEOUT = 30
14
+ DEFAULT_CONNECT_TIMEOUT = 10
15
+
16
+ module_function
17
+
18
+ def user_agent
19
+ "#{EDGAR_IDENTITY} (Ruby edgar gem #{Edgar::VERSION})"
20
+ end
21
+
22
+ def submissions_url(cik)
23
+ padded = cik.to_s.rjust(10, "0")
24
+ "#{SEC_DATA_URL}/submissions/CIK#{padded}.json"
25
+ end
26
+
27
+ def company_facts_url(cik)
28
+ padded = cik.to_s.rjust(10, "0")
29
+ "#{SEC_DATA_URL}/api/xbrl/companyfacts/CIK#{padded}.json"
30
+ end
31
+
32
+ def full_index_url(year, quarter, type = "company")
33
+ "#{SEC_ARCHIVE_URL}/full-index/#{year}/QTR#{quarter}/#{type}.gz"
34
+ end
35
+
36
+ def daily_index_url(year, quarter, date)
37
+ "#{SEC_ARCHIVE_URL}/daily-index/#{year}/QTR#{quarter}/#{date}.idx"
38
+ end
39
+
40
+ def filing_homepage_url(cik, accession_number)
41
+ "#{SEC_ARCHIVE_URL}/data/#{cik}/#{accession_number}-index.html"
42
+ end
43
+
44
+ def filing_text_url(cik, accession_number)
45
+ no_dashes = accession_number.delete("-")
46
+ "#{SEC_ARCHIVE_URL}/data/#{cik}/#{no_dashes}/#{accession_number}.txt"
47
+ end
48
+
49
+ def filing_dir_url(cik, accession_number)
50
+ no_dashes = accession_number.delete("-")
51
+ "#{SEC_ARCHIVE_URL}/data/#{cik}/#{no_dashes}"
52
+ end
53
+
54
+ def tickers_url
55
+ "#{SEC_BASE_URL}/files/company_tickers.json"
56
+ end
57
+
58
+ def submissions_file_url(_cik, filename)
59
+ "#{SEC_DATA_URL}/submissions/#{filename}"
60
+ end
61
+ end
62
+ end