Skip to content

Commit 91d82fb

Browse files
authored
Merge pull request #851 from gaoflow/fix-850-currency-iso4217
Add ISO 4217 currency column to XBRL facts DataFrame (#850)
2 parents 1498d6a + f13f4a5 commit 91d82fb

3 files changed

Lines changed: 126 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **`currency` column on the XBRL facts DataFrame**`xbrl().facts.to_dataframe()` now includes a `currency` column with each fact's ISO 4217 code (e.g. `USD`, `HKD`) resolved from its unit measure. Non-USD filers tag monetary facts with opaque unit ids such as `UNIT_STANDARD_HKD_MNUSOXGRF0O9R60JINVDUQ`, which were exposed verbatim in `unit_ref` and made currency-based filtering and display unreliable; the raw `unit_ref` is preserved, while `currency` gives a usable code. Per-share monetary units report their numerator currency, and non-monetary units (shares, pure, custom) resolve to `None` rather than a misleading value. ([#850](https://github.com/dgunning/edgartools/issues/850))
13+
1014
### Fixed
1115

1216
- **BDC data sets download again after SEC URL move** — the SEC relocated the DERA BDC data-set files from `/files/structureddata/data/` to `/files/datastandardsinnovation/data/`, which made `fetch_bdc_dataset()` and related functions fail with 404; the base URL now points to the new location.

edgar/xbrl/facts.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,36 @@ def _deduplicate_facts(df: pd.DataFrame) -> pd.DataFrame:
4343
return df
4444

4545

46+
def _iso4217_code(measure: Optional[str]) -> Optional[str]:
47+
"""Return the ISO 4217 code of an ``iso4217:`` unit measure, else ``None``."""
48+
if measure and measure.startswith('iso4217:'):
49+
return measure[len('iso4217:'):]
50+
return None
51+
52+
53+
def _unit_currency(unit_info: Optional[Dict[str, Any]]) -> Optional[str]:
54+
"""Resolve a parsed XBRL unit to its ISO 4217 currency code, or ``None``.
55+
56+
Currency facts use a simple ``iso4217:`` measure (e.g. ``iso4217:HKD`` ->
57+
``HKD``). Per-share monetary facts use a ``divide`` unit whose numerator is
58+
the currency (e.g. ``iso4217:USD`` per ``xbrli:shares``), so the numerator
59+
currency is returned. Non-monetary units (shares, pure, ...) return ``None``.
60+
The opaque ``unit_ref`` id itself (e.g. ``UNIT_STANDARD_HKD_...``) is never
61+
parsed -- only the resolved measure is used (see issue #850).
62+
"""
63+
if not unit_info:
64+
return None
65+
unit_type = unit_info.get('type')
66+
if unit_type == 'simple':
67+
return _iso4217_code(unit_info.get('measure'))
68+
if unit_type == 'divide':
69+
for measure in unit_info.get('numerator', []):
70+
code = _iso4217_code(measure)
71+
if code:
72+
return code
73+
return None
74+
75+
4676
class FactQuery:
4777
"""
4878
A query builder for XBRL facts that enables filtering by various attributes.
@@ -1005,6 +1035,10 @@ def get_facts(self) -> List[Dict[str, Any]]:
10051035
# Build enriched facts from raw facts, contexts, and elements
10061036
enriched_facts = []
10071037

1038+
# Resolve each fact's opaque unit_ref to its ISO 4217 currency once
1039+
# (e.g. "UNIT_STANDARD_HKD_..." -> "HKD"); see issue #850.
1040+
units = self.xbrl.units
1041+
10081042
for fact_key, fact in self.xbrl._facts.items():
10091043
# Create a dict with only necessary fields instead of full model_dump
10101044
fact_dict = {
@@ -1014,6 +1048,7 @@ def get_facts(self) -> List[Dict[str, Any]]:
10141048
'context_ref': fact.context_ref,
10151049
'value': fact.value,
10161050
'unit_ref': fact.unit_ref,
1051+
'currency': _unit_currency(units.get(fact.unit_ref)) if fact.unit_ref else None,
10171052
'decimals': fact.decimals,
10181053
'numeric_value': fact.numeric_value
10191054
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""
2+
Regression test for Issue #850: Normalize currency unit identifiers to ISO 4217.
3+
4+
Problem: ``xbrl().facts.to_dataframe()`` exposed the raw XBRL ``unit_ref`` id for
5+
every fact. For non-USD filers the unit id is an opaque token such as
6+
``UNIT_STANDARD_HKD_MNUSOXGRF0O9R60JINVDUQ`` instead of a usable currency, which
7+
made currency-based filtering and display unreliable for foreign companies.
8+
9+
Fix: the facts DataFrame now carries a ``currency`` column that resolves each
10+
fact's unit to its ISO 4217 code (e.g. ``HKD``) via the parsed unit measure
11+
(``iso4217:HKD``). The opaque ``unit_ref`` is preserved unchanged; non-monetary
12+
units (shares, pure, custom) resolve to ``None`` rather than a misleading value.
13+
14+
Reporter: warzoo
15+
See: https://github.com/dgunning/edgartools/issues/850
16+
"""
17+
18+
from pathlib import Path
19+
20+
from edgar.xbrl import XBRL
21+
22+
# A minimal XBRL instance whose currency unit uses the opaque id from the issue.
23+
FOREIGN_INSTANCE = """<?xml version="1.0" encoding="UTF-8"?>
24+
<xbrl xmlns="http://www.xbrl.org/2003/instance"
25+
xmlns:iso4217="http://www.xbrl.org/2003/iso4217"
26+
xmlns:xbrli="http://www.xbrl.org/2003/instance"
27+
xmlns:us-gaap="http://fasb.org/us-gaap/2023">
28+
<context id="c1">
29+
<entity><identifier scheme="http://www.sec.gov/CIK">0001234567</identifier></entity>
30+
<period><startDate>2024-01-01</startDate><endDate>2024-12-31</endDate></period>
31+
</context>
32+
<unit id="UNIT_STANDARD_HKD_MNUSOXGRF0O9R60JINVDUQ">
33+
<measure>iso4217:HKD</measure>
34+
</unit>
35+
<us-gaap:Revenues contextRef="c1"
36+
unitRef="UNIT_STANDARD_HKD_MNUSOXGRF0O9R60JINVDUQ" decimals="-3">1500000</us-gaap:Revenues>
37+
</xbrl>
38+
"""
39+
40+
41+
def test_unit_currency_helper():
42+
"""``_unit_currency`` extracts the ISO 4217 code, ignoring non-currency units."""
43+
from edgar.xbrl.facts import _unit_currency
44+
45+
assert _unit_currency({"type": "simple", "measure": "iso4217:HKD"}) == "HKD"
46+
assert _unit_currency({"type": "simple", "measure": "iso4217:USD"}) == "USD"
47+
# Per-share monetary units (currency / shares) report the numerator currency.
48+
assert _unit_currency({"type": "divide", "numerator": ["iso4217:USD"], "denominator": ["xbrli:shares"]}) == "USD"
49+
# Non-monetary or unknown units resolve to None, not a misleading value.
50+
assert _unit_currency({"type": "simple", "measure": "shares"}) is None
51+
assert _unit_currency({"type": "simple", "measure": "xbrli:pure"}) is None
52+
assert _unit_currency(None) is None
53+
54+
55+
def test_opaque_unit_ref_resolves_to_iso4217_currency(tmp_path):
56+
"""The issue's repro: an opaque HKD unit id resolves to ``HKD`` in ``currency``."""
57+
instance_file = tmp_path / "foreign.xml"
58+
instance_file.write_text(FOREIGN_INSTANCE)
59+
60+
xbrl = XBRL.from_files(instance_file=instance_file)
61+
df = xbrl.facts.to_dataframe()
62+
63+
assert "currency" in df.columns
64+
revenue = df[df["concept"].str.contains("Revenue", case=False, na=False)]
65+
assert len(revenue) == 1
66+
# The opaque unit_ref is preserved unchanged ...
67+
assert revenue["unit_ref"].iloc[0] == "UNIT_STANDARD_HKD_MNUSOXGRF0O9R60JINVDUQ"
68+
# ... and the currency is now the usable ISO 4217 code.
69+
assert revenue["currency"].iloc[0] == "HKD"
70+
71+
72+
def test_currency_column_ground_truth_aapl():
73+
"""A real filing (AAPL 10-K) resolves monetary facts to their currency codes."""
74+
aapl = XBRL.from_directory(Path("tests/fixtures/xbrl/aapl/10k_2023"))
75+
df = aapl.facts.to_dataframe()
76+
77+
assert "currency" in df.columns
78+
# USD-denominated facts resolve to "USD" (not the raw "usd" unit id) ...
79+
usd = df[df["currency"] == "USD"]
80+
assert len(usd) > 100
81+
assert (usd["unit_ref"] == "usd").any()
82+
# ... and per-share amounts (unit_ref "usdPerShare") also resolve to USD.
83+
assert (df.loc[df["unit_ref"] == "usdPerShare", "currency"] == "USD").all()
84+
# Silence check: share-count facts are not a currency, so currency is None.
85+
shares = df[df["unit_ref"] == "shares"]
86+
assert len(shares) > 0
87+
assert shares["currency"].isna().all()

0 commit comments

Comments
 (0)