|
| 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