Skip to content

Commit 3de0d5f

Browse files
committed
Add hierarchical concept standardization
1 parent d722a18 commit 3de0d5f

4 files changed

Lines changed: 194 additions & 23 deletions

File tree

edgar/xbrl/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
Example usage:
88
99
from edgar import Company
10-
from edgar.xbrl2 import XBRL, XBRLS
10+
from edgar.xbrl import XBRL, XBRLS
1111
1212
# Parse a single filing
1313
company = Company("AAPL")

edgar/xbrl/models.py

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def select_display_label(
2525
) -> str:
2626
"""
2727
Select the most appropriate label for display, following a consistent priority order.
28+
Includes standardization mapping to provide consistent labels across companies.
2829
2930
Args:
3031
labels: Dictionary of available labels
@@ -34,34 +35,62 @@ def select_display_label(
3435
element_name: Element name (alternative fallback)
3536
3637
Returns:
37-
The selected label according to priority rules
38+
The selected label according to priority rules, with standardization applied if available
3839
"""
40+
# First, select the best available label using existing priority logic
41+
selected_label = None
42+
3943
# 1. Use preferred label if specified and available
4044
if preferred_label and labels and preferred_label in labels:
41-
return labels[preferred_label]
45+
selected_label = labels[preferred_label]
4246

4347
# 2. Use terse label if available (more user-friendly)
44-
if labels and TERSE_LABEL in labels:
45-
return labels[TERSE_LABEL]
48+
elif labels and TERSE_LABEL in labels:
49+
selected_label = labels[TERSE_LABEL]
4650

4751
# 3. Fall back to standard label
48-
if standard_label:
49-
return standard_label
52+
elif standard_label:
53+
selected_label = standard_label
5054

5155
# 4. Try STANDARD_LABEL directly from labels dict
52-
if labels and STANDARD_LABEL in labels:
53-
return labels[STANDARD_LABEL]
56+
elif labels and STANDARD_LABEL in labels:
57+
selected_label = labels[STANDARD_LABEL]
5458

5559
# 5. Take any available label
56-
if labels:
57-
return next(iter(labels.values()), "")
60+
elif labels:
61+
selected_label = next(iter(labels.values()), "")
5862

5963
# 6. Use element name if available
60-
if element_name:
61-
return element_name
64+
elif element_name:
65+
selected_label = element_name
6266

6367
# 7. Last resort: element ID
64-
return element_id or ""
68+
else:
69+
selected_label = element_id or ""
70+
71+
# Apply standardization if we have an element_id (concept)
72+
if element_id and selected_label:
73+
try:
74+
from edgar.xbrl.standardization.core import initialize_default_mappings
75+
76+
# Initialize mapping store (cached after first call)
77+
if not hasattr(select_display_label, '_mapping_store'):
78+
select_display_label._mapping_store = initialize_default_mappings(read_only=True)
79+
80+
# Try to get standardized concept
81+
standardized_label = select_display_label._mapping_store.get_standard_concept(element_id)
82+
83+
if standardized_label:
84+
return standardized_label
85+
86+
except ImportError:
87+
# Standardization not available, continue with selected label
88+
pass
89+
except Exception:
90+
# Any other error in standardization, continue with selected label
91+
pass
92+
93+
return selected_label
6594

6695

6796
class ElementCatalog(BaseModel):

edgar/xbrl/standardization/concept_mappings.json

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,21 @@
2727
"us-gaap_ResearchAndDevelopmentCosts",
2828
"us-gaap_ResearchAndDevelopmentExpense"
2929
],
30+
"_comment_sga_hierarchy": "SG&A HIERARCHY FIX: Separated total SG&A from components to prevent duplicate labels. Previously all three concepts below mapped to 'Selling, General and Administrative Expense' causing confusion when companies report both total and components.",
3031
"Selling, General and Administrative Expense": [
31-
"us-gaap_SellingGeneralAndAdministrativeExpense",
32+
"us-gaap_SellingGeneralAndAdministrativeExpense"
33+
],
34+
"General and Administrative Expense": [
3235
"us-gaap_GeneralAndAdministrativeExpense",
33-
"us-gaap_SellingAndMarketingExpense"
36+
"us-gaap_AdministrativeExpense"
37+
],
38+
"Selling Expense": [
39+
"us-gaap_SellingAndMarketingExpense",
40+
"us-gaap_SellingExpense"
41+
],
42+
"Marketing Expense": [
43+
"us-gaap_MarketingExpense",
44+
"us-gaap_AdvertisingExpense"
3445
],
3546
"Operating Income": [
3647
"us-gaap_OperatingIncomeLoss",

edgar/xbrl/standardization/core.py

Lines changed: 138 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,35 @@ class StandardConcept(str, Enum):
4747
RETAINED_EARNINGS = "Retained Earnings"
4848
TOTAL_EQUITY = "Total Stockholders' Equity"
4949

50-
# Income Statement
50+
# Income Statement - Revenue Hierarchy
5151
REVENUE = "Revenue"
52+
PRODUCT_REVENUE = "Product Revenue"
53+
SERVICE_REVENUE = "Service Revenue"
54+
SUBSCRIPTION_REVENUE = "Subscription Revenue"
55+
LEASING_REVENUE = "Leasing Revenue"
56+
57+
# Industry-Specific Revenue Concepts
58+
AUTOMOTIVE_REVENUE = "Automotive Revenue"
59+
AUTOMOTIVE_LEASING_REVENUE = "Automotive Leasing Revenue"
60+
ENERGY_REVENUE = "Energy Revenue"
61+
SOFTWARE_REVENUE = "Software Revenue"
62+
HARDWARE_REVENUE = "Hardware Revenue"
63+
PLATFORM_REVENUE = "Platform Revenue"
64+
65+
# Income Statement - Expenses
5266
COST_OF_REVENUE = "Cost of Revenue"
5367
GROSS_PROFIT = "Gross Profit"
5468
OPERATING_EXPENSES = "Operating Expenses"
5569
RESEARCH_AND_DEVELOPMENT = "Research and Development Expense"
70+
71+
# Enhanced Expense Hierarchy
5672
SELLING_GENERAL_ADMIN = "Selling, General and Administrative Expense"
73+
SELLING_EXPENSE = "Selling Expense"
74+
GENERAL_ADMIN_EXPENSE = "General and Administrative Expense"
75+
MARKETING_EXPENSE = "Marketing Expense"
76+
SALES_EXPENSE = "Sales Expense"
77+
78+
# Other Income Statement
5779
OPERATING_INCOME = "Operating Income"
5880
INTEREST_EXPENSE = "Interest Expense"
5981
INCOME_BEFORE_TAX = "Income Before Tax"
@@ -100,6 +122,8 @@ class MappingStore:
100122
Attributes:
101123
source (str): Path to the JSON file storing the mappings
102124
mappings (Dict[str, Set[str]]): Dictionary mapping standard concepts to sets of company concepts
125+
company_mappings (Dict[str, Dict]): Company-specific mappings loaded from company_mappings/
126+
merged_mappings (Dict[str, List[Tuple]]): Merged mappings with priority scoring
103127
"""
104128

105129
def __init__(self, source: Optional[str] = None, validate_with_enum: bool = False, read_only: bool = False):
@@ -113,6 +137,7 @@ def __init__(self, source: Optional[str] = None, validate_with_enum: bool = Fals
113137
"""
114138
self.read_only = read_only
115139

140+
116141
if source is None:
117142
# Try a few different ways to locate the file, handling both development
118143
# and installed package scenarios
@@ -130,15 +155,15 @@ def __init__(self, source: Optional[str] = None, validate_with_enum: bool = Fals
130155
import importlib.resources as pkg_resources
131156
try:
132157
# For Python 3.9+
133-
with pkg_resources.files('edgar.xbrl2.standardization').joinpath('concept_mappings.json').open('r') as f:
158+
with pkg_resources.files('edgar.xbrl.standardization').joinpath('concept_mappings.json').open('r') as f:
134159
# Just read the file to see if it exists, we'll load it properly later
135160
f.read(1)
136161
self.source = potential_path # Use the same path as before
137162
except (ImportError, FileNotFoundError, AttributeError):
138163
# Fallback for older Python versions
139164
try:
140165
import pkg_resources as legacy_resources
141-
if legacy_resources.resource_exists('edgar.xbrl2.standardization', 'concept_mappings.json'):
166+
if legacy_resources.resource_exists('edgar.xbrl.standardization', 'concept_mappings.json'):
142167
self.source = potential_path # Use the same path as before
143168
except (ImportError, FileNotFoundError):
144169
pass
@@ -154,6 +179,11 @@ def __init__(self, source: Optional[str] = None, validate_with_enum: bool = Fals
154179

155180
self.mappings = self._load_mappings()
156181

182+
# Load company-specific mappings (always enabled)
183+
self.company_mappings = self._load_all_company_mappings()
184+
self.merged_mappings = self._create_merged_mappings()
185+
self.hierarchy_rules = self._load_hierarchy_rules()
186+
157187
# Validate the loaded mappings against StandardConcept enum
158188
if validate_with_enum:
159189
self.validate_against_enum()
@@ -207,6 +237,79 @@ def to_dataframe(self) -> pd.DataFrame:
207237

208238
return pd.DataFrame(rows)
209239

240+
241+
def _load_all_company_mappings(self) -> Dict[str, Dict]:
242+
"""Load all company-specific mapping files from company_mappings/ directory."""
243+
mappings = {}
244+
company_dir = os.path.join(os.path.dirname(self.source or __file__), "company_mappings")
245+
246+
if os.path.exists(company_dir):
247+
for file in os.listdir(company_dir):
248+
if file.endswith("_mappings.json"):
249+
entity_id = file.replace("_mappings.json", "")
250+
try:
251+
with open(os.path.join(company_dir, file), 'r') as f:
252+
company_data = json.load(f)
253+
mappings[entity_id] = company_data
254+
except (FileNotFoundError, json.JSONDecodeError) as e:
255+
import logging
256+
logger = logging.getLogger(__name__)
257+
logger.warning(f"Failed to load {file}: {e}")
258+
259+
return mappings
260+
261+
def _create_merged_mappings(self) -> Dict[str, List[Tuple[str, str, int]]]:
262+
"""Create merged mappings with priority scoring.
263+
264+
Priority levels:
265+
1. Core mappings (lowest)
266+
2. Company mappings (higher)
267+
3. Company-specific matches (highest when company detected)
268+
269+
Returns:
270+
Dict mapping standard concepts to list of (company_concept, source, priority) tuples
271+
"""
272+
merged = {}
273+
274+
# Add core mappings (priority 1 - lowest)
275+
for std_concept, company_concepts in self.mappings.items():
276+
merged[std_concept] = []
277+
for concept in company_concepts:
278+
merged[std_concept].append((concept, "core", 1))
279+
280+
# Add company mappings (priority 2 - higher)
281+
for entity_id, company_data in self.company_mappings.items():
282+
concept_mappings = company_data.get("concept_mappings", {})
283+
priority_level = 2
284+
285+
for std_concept, company_concepts in concept_mappings.items():
286+
if std_concept not in merged:
287+
merged[std_concept] = []
288+
for concept in company_concepts:
289+
merged[std_concept].append((concept, entity_id, priority_level))
290+
291+
return merged
292+
293+
def _load_hierarchy_rules(self) -> Dict[str, Dict]:
294+
"""Load hierarchy rules from company mappings."""
295+
all_rules = {}
296+
297+
# Add company hierarchy rules
298+
for entity_id, company_data in self.company_mappings.items():
299+
hierarchy_rules = company_data.get("hierarchy_rules", {})
300+
all_rules.update(hierarchy_rules)
301+
302+
return all_rules
303+
304+
def _detect_entity_from_concept(self, concept: str) -> Optional[str]:
305+
"""Detect entity identifier from concept name prefix."""
306+
if ':' in concept:
307+
prefix = concept.split(':')[0].lower()
308+
# Check if this prefix corresponds to a known company
309+
if prefix in self.company_mappings:
310+
return prefix
311+
return None
312+
210313
def _load_mappings(self) -> Dict[str, Set[str]]:
211314
"""
212315
Load mappings from the JSON file.
@@ -228,12 +331,12 @@ def _load_mappings(self) -> Dict[str, Set[str]]:
228331
import importlib.resources as pkg_resources
229332
try:
230333
# For Python 3.9+
231-
with pkg_resources.files('edgar.xbrl2.standardization').joinpath('concept_mappings.json').open('r') as f:
334+
with pkg_resources.files('edgar.xbrl.standardization').joinpath('concept_mappings.json').open('r') as f:
232335
data = json.load(f)
233336
except (ImportError, FileNotFoundError, AttributeError):
234337
# Fallback to legacy pkg_resources
235338
import pkg_resources as legacy_resources
236-
resource_string = legacy_resources.resource_string('edgar.xbrl2.standardization', 'concept_mappings.json')
339+
resource_string = legacy_resources.resource_string('edgar.xbrl.standardization', 'concept_mappings.json')
237340
data = json.loads(resource_string)
238341
except ImportError:
239342
pass
@@ -292,16 +395,44 @@ def add(self, company_concept: str, standard_concept: str) -> None:
292395
self.mappings[standard_concept].add(company_concept)
293396
self._save_mappings()
294397

295-
def get_standard_concept(self, company_concept: str) -> Optional[str]:
398+
def get_standard_concept(self, company_concept: str, context: Dict = None) -> Optional[str]:
296399
"""
297-
Get the standard concept for a given company concept.
400+
Get the standard concept for a given company concept with priority-based resolution.
298401
299402
Args:
300403
company_concept: The company-specific concept
404+
context: Optional context information (not used in current implementation)
301405
302406
Returns:
303407
The standard concept or None if not found
304408
"""
409+
# Use merged mappings with priority-based resolution
410+
if self.merged_mappings:
411+
# Detect company from concept prefix (e.g., 'tsla:Revenue' -> 'tsla')
412+
detected_entity = self._detect_entity_from_concept(company_concept)
413+
414+
# Search through merged mappings with priority
415+
candidates = []
416+
417+
for std_concept, mapping_list in self.merged_mappings.items():
418+
for concept, source, priority in mapping_list:
419+
if concept == company_concept:
420+
# Boost priority if it matches detected entity
421+
effective_priority = priority
422+
if detected_entity and source == detected_entity:
423+
effective_priority = 4 # Highest priority for exact company match
424+
425+
candidates.append((std_concept, effective_priority, source))
426+
427+
# Return highest priority match
428+
if candidates:
429+
best_match = max(candidates, key=lambda x: x[1])
430+
import logging
431+
logger = logging.getLogger(__name__)
432+
logger.debug(f"Mapping applied: {company_concept} -> {best_match[0]} (source: {best_match[2]}, priority: {best_match[1]})")
433+
return best_match[0]
434+
435+
# Fallback to core mappings
305436
for standard_concept, company_concepts in self.mappings.items():
306437
if company_concept in company_concepts:
307438
return standard_concept

0 commit comments

Comments
 (0)