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,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
|
data/lib/edgar/filing.rb
ADDED
|
@@ -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
|