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,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edgar
4
+ # Calculates Trailing Twelve Months (TTM) values from quarterly financial facts.
5
+ #
6
+ # Matches Python edgartools' TTMCalculator algorithm:
7
+ # 1. Classify facts by period duration (quarter: 70-120d, YTD_6M: 140-229d,
8
+ # YTD_9M: 230-329d, annual: 330-420d)
9
+ # 2. Derive Q2=YTD_6M-Q1, Q3=YTD_9M-YTD_6M, Q4=FY-YTD_9M
10
+ # 3. Take 4 most recent quarters and sum them.
11
+ # Falls back to fp-based quarterly selection when values lack start dates.
12
+ class TTMCalculator
13
+ QUARTER_MIN_DAYS = 70
14
+ QUARTER_MAX_DAYS = 120
15
+ YTD_6M_MIN_DAYS = 140
16
+ YTD_6M_MAX_DAYS = 229
17
+ YTD_9M_MIN_DAYS = 230
18
+ YTD_9M_MAX_DAYS = 329
19
+ ANNUAL_MIN_DAYS = 330
20
+ ANNUAL_MAX_DAYS = 420
21
+
22
+ PERIODIC_FORMS = %w[10-K 10-K/A 10-Q 10-Q/A 20-F 20-F/A 40-F 40-F/A 6-K 6-K/A].freeze
23
+
24
+ def initialize(values)
25
+ @values = values
26
+ end
27
+
28
+ # Returns a hash with "end" and "val" keys, or nil if insufficient data.
29
+ def calculate
30
+ all_quarters = quarterize(@values)
31
+ return nil if all_quarters.length < 4
32
+
33
+ ttm_qs = all_quarters.last(4)
34
+ sum = ttm_qs.sum { |v| v["val"].to_f }
35
+ { "end" => ttm_qs.last["end"], "val" => sum }
36
+ end
37
+
38
+ private
39
+
40
+ def quarterize(values)
41
+ quarters, ytd_6m, ytd_9m, annual_facts = classify_by_duration(values)
42
+
43
+ return fp_based_quarters(values) if all_empty?(quarters, ytd_6m, ytd_9m, annual_facts)
44
+
45
+ quarters = dedup_by_end(quarters)
46
+ ytd_6m = dedup_by_end(ytd_6m)
47
+ ytd_9m = dedup_by_end(ytd_9m)
48
+ annual_facts = dedup_by_end(annual_facts)
49
+
50
+ derived = []
51
+ derived.concat(derive_q2(quarters, ytd_6m))
52
+ derived.concat(derive_q3(ytd_6m, ytd_9m))
53
+ derived.concat(derive_q4(quarters, ytd_9m, annual_facts))
54
+
55
+ all_quarters = quarters + derived
56
+ all_quarters = dedup_by_end(all_quarters)
57
+ all_quarters.sort_by { |v| v["end"] || v["start"] || "" }
58
+ end
59
+
60
+ def all_empty?(*arrays)
61
+ arrays.all?(&:empty?)
62
+ end
63
+
64
+ def classify_by_duration(values)
65
+ quarters = []
66
+ ytd_6m = []
67
+ ytd_9m = []
68
+ annual_facts = []
69
+
70
+ values.each do |v|
71
+ days = duration_days(v["start"], v["end"])
72
+ next unless days
73
+
74
+ if days.between?(QUARTER_MIN_DAYS, QUARTER_MAX_DAYS)
75
+ quarters << v
76
+ elsif days.between?(YTD_6M_MIN_DAYS, YTD_6M_MAX_DAYS)
77
+ ytd_6m << v
78
+ elsif days.between?(YTD_9M_MIN_DAYS, YTD_9M_MAX_DAYS)
79
+ ytd_9m << v
80
+ elsif days.between?(ANNUAL_MIN_DAYS, ANNUAL_MAX_DAYS)
81
+ annual_facts << v
82
+ end
83
+ end
84
+
85
+ [quarters, ytd_6m, ytd_9m, annual_facts]
86
+ end
87
+
88
+ def fp_based_quarters(values)
89
+ has_fp = values.first&.key?("fp")
90
+ return [] unless has_fp
91
+
92
+ quarterly = values.select { |v| %w[Q1 Q2 Q3 Q4].include?(v["fp"]) }
93
+ quarterly.sort_by { |v| v["end"] || v["start"] || "" }
94
+ end
95
+
96
+ def duration_days(start_str, end_str)
97
+ return nil unless start_str && end_str
98
+
99
+ (Date.parse(end_str) - Date.parse(start_str)).to_i
100
+ rescue ArgumentError
101
+ nil
102
+ end
103
+
104
+ def dedup_by_end(values)
105
+ return [] if values.empty?
106
+
107
+ by_end = {}
108
+ values.each do |v|
109
+ key = v["end"]
110
+ next unless key
111
+
112
+ existing = by_end[key]
113
+ by_end[key] = v if existing.nil? || (filing_priority(v) <=> filing_priority(existing)).positive?
114
+ end
115
+ by_end.values.sort_by { |v| v["end"] || "" }
116
+ end
117
+
118
+ def filing_priority(value)
119
+ form = value["form"] || ""
120
+ tier = PERIODIC_FORMS.include?(form) ? 1 : 0
121
+ [tier, value["filed"] || ""]
122
+ end
123
+
124
+ def derive_q2(quarters, ytd_6m)
125
+ derived = []
126
+ ytd_6m.each do |ytd6|
127
+ q1 = find_prior_by_end(quarters, before: ytd6["end"])
128
+ next unless q1
129
+
130
+ q2_val = ytd6["val"].to_f - q1["val"].to_f
131
+ next if q2_val.negative?
132
+
133
+ derived << { "val" => q2_val, "end" => ytd6["end"], "fp" => "Q2" }
134
+ end
135
+ derived
136
+ end
137
+
138
+ def derive_q3(ytd_6m, ytd_9m)
139
+ derived = []
140
+ ytd_9m.each do |ytd9|
141
+ ytd6 = find_prior_by_end(ytd_6m, before: ytd9["end"])
142
+ next unless ytd6
143
+
144
+ q3_val = ytd9["val"].to_f - ytd6["val"].to_f
145
+ next if q3_val.negative?
146
+
147
+ derived << { "val" => q3_val, "end" => ytd9["end"], "fp" => "Q3" }
148
+ end
149
+ derived
150
+ end
151
+
152
+ def derive_q4(quarters, ytd_9m, annual_facts)
153
+ derived = []
154
+ annual_facts.each do |fy|
155
+ ytd9 = find_prior_by_end(ytd_9m, before: fy["end"])
156
+ unless ytd9
157
+ fy_quarters = quarters.select do |q|
158
+ q["end"] && fy["end"] && q["end"] < fy["end"] &&
159
+ fy["start"] && q["start"] && q["start"] >= fy["start"]
160
+ end
161
+ fy_quarters = dedup_by_end(fy_quarters)
162
+ next if fy_quarters.length < 3
163
+
164
+ q1q2q3 = fy_quarters.last(3)
165
+ q4_val = fy["val"].to_f - q1q2q3.sum { |q| q["val"].to_f }
166
+ next if q4_val.negative?
167
+
168
+ derived << { "val" => q4_val, "end" => fy["end"], "fp" => "Q4" }
169
+ next
170
+ end
171
+
172
+ q4_val = fy["val"].to_f - ytd9["val"].to_f
173
+ next if q4_val.negative?
174
+
175
+ derived << { "val" => q4_val, "end" => fy["end"], "fp" => "Q4" }
176
+ end
177
+ derived
178
+ end
179
+
180
+ def find_prior_by_end(values, before:)
181
+ candidates = values.select { |v| v["end"] && before && v["end"] < before }
182
+ candidates.max_by { |v| v["end"] || "" }
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edgar
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Edgar
6
+ module XBRL
7
+ # Parses XBRL XML documents for financial fact extraction.
8
+ class XBRL
9
+ attr_reader :raw
10
+
11
+ def initialize(xml_content)
12
+ @raw = xml_content
13
+ @doc = parse_document(xml_content)
14
+ end
15
+
16
+ def context_refs
17
+ @doc.css("context").map do |ctx|
18
+ id = ctx["id"]
19
+ entity = ctx.at_css("entity")
20
+ period = ctx.at_css("period")
21
+ start_el = period&.at_css("startDate")
22
+ end_el = period&.at_css("endDate")
23
+ instant_el = period&.at_css("instant")
24
+ # Detect segment dimensions (product/service, geographic, etc.)
25
+ segment = entity&.at_css("segment")
26
+ {
27
+ id: id,
28
+ entity: entity&.text&.strip,
29
+ start_date: start_el&.text&.strip,
30
+ end_date: end_el&.text&.strip,
31
+ instant: instant_el&.text&.strip,
32
+ segmented: !segment.nil?
33
+ }
34
+ end
35
+ end
36
+
37
+ def facts
38
+ xbrl_elements = @doc.xpath("//*[local-name() != 'context' and local-name() != 'unit' and local-name() != 'schemaRef' and local-name() != 'linkbaseRef']").select do |el|
39
+ ns = el.namespace
40
+ ns&.href&.include?("xbrl")
41
+ end
42
+ xbrl_elements.map do |el|
43
+ {
44
+ name: el.name,
45
+ namespace: el.namespace&.href,
46
+ value: el.text.strip,
47
+ context_ref: el["contextRef"],
48
+ unit_ref: el["unitRef"],
49
+ decimals: el["decimals"],
50
+ signature: el["signature"]
51
+ }
52
+ end
53
+ end
54
+
55
+ def units
56
+ @doc.css("unit").map do |u|
57
+ { id: u["id"], measure: u.at_css("measure")&.text&.strip }
58
+ end
59
+ end
60
+
61
+ def financial_concepts
62
+ ns_prefixes = @doc.namespaces.select { |_, v| v&.include?("us-gaap") || v&.include?("dei") }
63
+ return [] if ns_prefixes.empty?
64
+
65
+ financial_ns = ns_prefixes.map { |k, _| k.sub("xmlns:", "") }
66
+ xpath_expr = financial_ns.map { |p| "//#{p}:*" }.join(" | ")
67
+ @doc.xpath(xpath_expr).filter_map do |el|
68
+ next unless el["contextRef"]
69
+
70
+ {
71
+ name: el.name,
72
+ value: el.text.strip,
73
+ context_ref: el["contextRef"],
74
+ unit_ref: el["unitRef"],
75
+ decimals: el["decimals"]
76
+ }
77
+ end
78
+ end
79
+
80
+ def to_s
81
+ "#<XBRL facts=#{facts.length}>"
82
+ end
83
+
84
+ private
85
+
86
+ def parse_document(content)
87
+ Nokogiri::XML(content) { |config| config.strict.noblanks }
88
+ rescue Nokogiri::XML::SyntaxError
89
+ Nokogiri::XML(content)
90
+ end
91
+ end
92
+
93
+ # Parses Inline XBRL (iXBRL) HTML documents with embedded financial facts.
94
+ class InlineXBRL < XBRL
95
+ def facts
96
+ inline_elements = @doc.css("[name]").select do |el|
97
+ el["name"].to_s.include?(":") && el["contextref"]
98
+ end
99
+ inline_elements.map do |el|
100
+ {
101
+ name: el["name"],
102
+ value: el.text.strip,
103
+ context_ref: el["contextref"],
104
+ unit_ref: el["unitref"],
105
+ decimals: el["decimals"],
106
+ format: el["format"],
107
+ scale: el["scale"]
108
+ }
109
+ end
110
+ end
111
+
112
+ def context_refs
113
+ @doc.css("context").map do |ctx|
114
+ {
115
+ id: ctx["id"],
116
+ start_date: ctx.at_css("startDate")&.text&.strip,
117
+ end_date: ctx.at_css("endDate")&.text&.strip,
118
+ instant: ctx.at_css("instant")&.text&.strip
119
+ }
120
+ end
121
+ end
122
+
123
+ def financial_concepts
124
+ facts.select { |f| f[:name]&.include?("us-gaap") || f[:name]&.include?("dei") }
125
+ end
126
+
127
+ private
128
+
129
+ def parse_document(content)
130
+ Nokogiri::HTML(content)
131
+ end
132
+ end
133
+ end
134
+ end
data/lib/edgar.rb ADDED
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require "json"
5
+ require "zlib"
6
+ require "stringio"
7
+ require "date"
8
+
9
+ loader = Zeitwerk::Loader.for_gem
10
+ loader.inflector.inflect(
11
+ "http_client" => "HTTPClient",
12
+ "xbrl" => "XBRL",
13
+ "ttm_calculator" => "TTMCalculator"
14
+ )
15
+ loader.setup
16
+
17
+ # Main entry point for the Edgar library providing SEC EDGAR filing access.
18
+ module Edgar
19
+ class << self
20
+ def http_client
21
+ @http_client ||= HTTPClient.new
22
+ end
23
+
24
+ attr_writer :http_client
25
+
26
+ def configure
27
+ yield(self)
28
+ end
29
+
30
+ def company(identifier)
31
+ if identifier.is_a?(Integer) || identifier.to_s.match?(/^\d+$/)
32
+ Company.new(cik: identifier.to_i)
33
+ else
34
+ Company.new(ticker: identifier.to_s)
35
+ end
36
+ end
37
+
38
+ def identity=(email)
39
+ ENV["EDGAR_IDENTITY"] = email
40
+ @http_client = nil
41
+ end
42
+
43
+ def get_filings(year, quarter)
44
+ Filings.from_quarter(year, quarter)
45
+ end
46
+
47
+ def current_filings
48
+ today = Date.today
49
+ Filings.from_quarter(today.year, ((today.month - 1) / 3) + 1)
50
+ end
51
+
52
+ def get_entity(cik)
53
+ Entity.new(cik: cik)
54
+ end
55
+
56
+ def find_company(name, limit: 10)
57
+ Company.search(name, limit: limit)
58
+ end
59
+
60
+ def find(identifier)
61
+ return nil unless identifier
62
+
63
+ str = identifier.to_s.strip
64
+ return Company.new(cik: str.to_i) if str.match?(/^\d{10}$/)
65
+ return Company.new(cik: str.to_i) if str.match?(/^\d+$/) && str.length >= 6
66
+ return Company.new(ticker: str) if str.match?(/^[A-Za-z]{1,5}$/)
67
+
68
+ Company.search(str, limit: 1).first
69
+ end
70
+
71
+ def obj(filing)
72
+ DataObjects.for(filing)
73
+ end
74
+
75
+ def reset!
76
+ @http_client = nil
77
+ end
78
+
79
+ def version
80
+ VERSION
81
+ end
82
+ end
83
+ end
metadata ADDED
@@ -0,0 +1,164 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby-edgar
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Edgar Tools Ruby Port
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-12 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday-retry
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: nokogiri
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.15'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.15'
55
+ - !ruby/object:Gem::Dependency
56
+ name: zeitwerk
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '2.6'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '2.6'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.12'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.12'
83
+ - !ruby/object:Gem::Dependency
84
+ name: vcr
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '6.0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '6.0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: webmock
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '3.18'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '3.18'
111
+ description: A Ruby port of the edgartools Python library for accessing and analyzing
112
+ SEC EDGAR filings, including company lookups, filing retrieval, and financial data
113
+ extraction.
114
+ email:
115
+ executables: []
116
+ extensions: []
117
+ extra_rdoc_files: []
118
+ files:
119
+ - README.md
120
+ - lib/edgar.rb
121
+ - lib/edgar/attachment.rb
122
+ - lib/edgar/attachments.rb
123
+ - lib/edgar/company.rb
124
+ - lib/edgar/config.rb
125
+ - lib/edgar/data_objects.rb
126
+ - lib/edgar/entity.rb
127
+ - lib/edgar/entity_data.rb
128
+ - lib/edgar/entity_facts.rb
129
+ - lib/edgar/filing.rb
130
+ - lib/edgar/filing_facts.rb
131
+ - lib/edgar/filing_header.rb
132
+ - lib/edgar/filing_homepage.rb
133
+ - lib/edgar/filing_index.rb
134
+ - lib/edgar/filing_sections.rb
135
+ - lib/edgar/filings.rb
136
+ - lib/edgar/financials.rb
137
+ - lib/edgar/http_client.rb
138
+ - lib/edgar/ttm_calculator.rb
139
+ - lib/edgar/version.rb
140
+ - lib/edgar/xbrl/xbrl.rb
141
+ homepage:
142
+ licenses:
143
+ - MIT
144
+ metadata: {}
145
+ post_install_message:
146
+ rdoc_options: []
147
+ require_paths:
148
+ - lib
149
+ required_ruby_version: !ruby/object:Gem::Requirement
150
+ requirements:
151
+ - - ">="
152
+ - !ruby/object:Gem::Version
153
+ version: '3.0'
154
+ required_rubygems_version: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - ">="
157
+ - !ruby/object:Gem::Version
158
+ version: '0'
159
+ requirements: []
160
+ rubygems_version: 3.3.7
161
+ signing_key:
162
+ specification_version: 4
163
+ summary: Ruby library to access SEC EDGAR filings
164
+ test_files: []