Files
OpenBB/openbb_platform/providers/sec/tests/test_asset_data.py
Danglewood 869cfed125 [Release] openbb-v5.0.0 (#7741)
* merge branch develop with main

* bump desktop version

* fix the merge

* sync Cargo.lock to desktop 2.0.0

* fix cli conftest.py

---------

Co-authored-by: deeleeramone <>
2026-09-29 10:58:44 -07:00

318 lines
12 KiB
Python

"""Unit tests for ``openbb_sec.utils.asset_data``."""
from io import BytesIO
from types import SimpleNamespace
from openbb_sec.utils import asset_data
def _cond(**kwargs):
"""Build an AgGrid filter condition stand-in."""
return SimpleNamespace(**kwargs)
def _sort(col_id, direction):
"""Build an AgGrid sort-model entry stand-in."""
return SimpleNamespace(colId=col_id, sort=direction)
def _request(*, filter_model=None, sort_model=None, start=0, end=0):
"""Build an SSRM query request stand-in."""
return SimpleNamespace(
filterModel=filter_model or {},
sortModel=sort_model or [],
startRow=start,
endRow=end,
)
class TestFetchStream:
"""The streaming, identity-encoded SEC fetch."""
def test_fetch_stream_sets_decode_and_headers(self, monkeypatch):
captured: dict = {}
raw = SimpleNamespace(decode_content=False)
resp = SimpleNamespace(raw=raw)
def _fake_request(url, headers=None, stream=None, timeout=None):
captured.update(url=url, headers=headers, stream=stream, timeout=timeout)
return resp
monkeypatch.setattr(
"openbb_sec.utils.ratelimit.sec_make_request", _fake_request
)
out = asset_data._fetch_stream("https://www.sec.gov/x.xml")
assert out is resp
assert raw.decode_content is True
assert captured["stream"] is True
assert captured["headers"]["Accept-Encoding"] == "identity"
class TestParseRecordsStream:
"""Streaming XML record parsing into columns and aligned rows."""
def test_parses_heterogeneous_records(self):
xml = (
b"<assetData>"
b"<asset><assetNumber>1</assetNumber><balance>100</balance></asset>"
b"<asset><assetNumber>2</assetNumber><!--c--><status>ok</status></asset>"
b"</assetData>"
)
columns, rows = asset_data.parse_records_stream(BytesIO(xml))
# Each asset's fields align into shared columns; comment children skip.
assert columns[:3] == ["assetNumber", "balance", "status"]
assert rows[:2] == [("1", "100", "", ""), ("2", "", "ok", "")]
# Artifact of the streaming clear/prune: once its asset children are
# emptied, the root collapses into one spurious, all-blank record under
# an ``asset`` column.
assert columns[3] == "asset"
assert rows[2] == ("", "", "", "")
def test_tail_text_definitions_disable_tabular_parsing(self):
xml = (
b"<assetData><assets>"
b"<assetTypeNumber>HCA</assetTypeNumber>"
b"<!-- comments -->"
b"<newEx103tag1>assetTypeNumber</newEx103tag1>"
b"<![CDATA[ Asset Number Type - HCA indicates Hyundai Capital America. ]]>"
b"<newEx103tag2>originatorName</newEx103tag2>"
b"<![CDATA[ Originator - HCA indicates Hyundai Capital America. ]]>"
b"</assets></assetData>"
)
columns, rows = asset_data.parse_records_stream(BytesIO(xml))
assert columns == []
assert rows == []
def test_non_asset_data_root_yields_nothing(self):
columns, rows = asset_data.parse_records_stream(
BytesIO(b"<xbrl><x>1</x></xbrl>")
)
assert columns == []
assert rows == []
class TestLoadAssetData:
"""Cached load of asset-data rows."""
def test_cache_hit_skips_fetch(self, monkeypatch):
called: dict = {}
monkeypatch.setattr(
"openbb_sec.utils.cache._make_key", lambda url, suffix="": "k"
)
monkeypatch.setattr(
"openbb_sec.utils.cache._cache_get", lambda key: (["a"], [("1",)])
)
monkeypatch.setattr(
asset_data, "_fetch_stream", lambda url: called.setdefault("fetched", True)
)
columns, rows = asset_data.load_asset_data("https://www.sec.gov/x.xml")
assert columns == ["a"]
assert rows == [("1",)]
assert "fetched" not in called
def test_cache_miss_fetches_parses_and_stores(self, monkeypatch):
stored: dict = {}
closed: dict = {}
monkeypatch.setattr(
"openbb_sec.utils.cache._make_key", lambda url, suffix="": "k"
)
monkeypatch.setattr("openbb_sec.utils.cache._cache_get", lambda key: None)
monkeypatch.setattr(
"openbb_sec.utils.cache._cache_set",
lambda key, value, expire: stored.update(value=value, expire=expire),
)
resp = SimpleNamespace(
raw=BytesIO(b"<assetData><asset><n>1</n></asset></assetData>"),
close=lambda: closed.setdefault("closed", True),
)
monkeypatch.setattr(asset_data, "_fetch_stream", lambda url: resp)
columns, rows = asset_data.load_asset_data("https://www.sec.gov/x.xml")
# ``n`` is the real field; the trailing ``asset`` column/blank row is the
# collapsed-root artifact (see TestParseRecordsStream). The whole parsed
# tuple is what gets cached.
assert columns == ["n", "asset"]
assert rows == [("1", ""), ("", "")]
assert closed["closed"] is True
assert stored["value"] == (["n", "asset"], [("1", ""), ("", "")])
assert stored["expire"] is None
class TestAsNumber:
"""Best-effort float conversion."""
def test_as_number(self):
assert asset_data._as_number("1.5") == 1.5
assert asset_data._as_number("x") is None
assert asset_data._as_number(None) is None
class TestBuildPredicate:
"""AgGrid filter-condition to row-predicate compilation."""
def test_set_filter(self):
pred = asset_data._build_predicate(
0, _cond(filterType="set", values=["a", "b"])
)
assert pred(("a",)) is True
assert pred(("c",)) is False
def test_values_present_forces_set_semantics(self):
pred = asset_data._build_predicate(0, _cond(filterType="text", values=["x"]))
assert pred(("x",)) is True
assert pred(("y",)) is False
def test_number_operators(self):
cases = [
("equals", "10", True),
("equals", "5", False),
("notequal", "5", True),
("greaterthan", "11", True),
("greaterthanorequal", "10", True),
("lessthan", "9", True),
("lessthanorequal", "10", True),
]
for op, value, expected in cases:
pred = asset_data._build_predicate(
0, _cond(filterType="number", type=op, filter="10")
)
assert pred((value,)) is expected, op
def test_number_inrange(self):
pred = asset_data._build_predicate(
0, _cond(filterType="number", type="inrange", filter="10", filterTo="20")
)
assert pred(("15",)) is True
assert pred(("25",)) is False
assert pred(("x",)) is False
def test_number_unknown_operator_is_none(self):
assert (
asset_data._build_predicate(
0, _cond(filterType="number", type="bogus", filter="1")
)
is None
)
def test_text_operators(self):
cases = [
("contains", "xABz", True),
("notcontains", "xyz", True),
("equals", "ab", True),
("notequal", "cd", True),
("startswith", "ABc", True),
("endswith", "xAB", True),
]
for op, value, expected in cases:
pred = asset_data._build_predicate(
0, _cond(filterType="text", type=op, filter="ab")
)
assert pred((value,)) is expected, op
def test_text_blank_operators(self):
blank = asset_data._build_predicate(0, _cond(filterType="text", type="blank"))
assert blank(("",)) is True
assert blank(("x",)) is False
notblank = asset_data._build_predicate(
0, _cond(filterType="text", type="notblank")
)
assert notblank(("x",)) is True
def test_text_default_contains_when_unknown_op(self):
pred = asset_data._build_predicate(0, _cond(type="", filter="foo"))
assert pred(("FooBar",)) is True
def test_text_default_empty_needle_is_none(self):
assert asset_data._build_predicate(0, _cond(type="", filter=None)) is None
class TestApplyFilters:
"""Row filtering by an AgGrid filterModel."""
columns = {"name": 0, "amt": 1}
rows = [("apple", "10"), ("banana", "20")]
def test_skips_unknown_columns_and_applies_predicate(self):
filter_model = {
"missing": _cond(filterType="text", type="contains", filter="x"),
"name": _cond(filterType="text", type="contains", filter="app"),
}
out = asset_data._apply_filters(self.columns, self.rows, filter_model)
assert out == [("apple", "10")]
def test_none_predicate_leaves_rows_unfiltered(self):
filter_model = {"name": _cond(type="", filter=None)}
out = asset_data._apply_filters(self.columns, self.rows, filter_model)
assert out is self.rows
def test_empty_filter_model_returns_rows(self):
assert asset_data._apply_filters(self.columns, self.rows, {}) is self.rows
class TestApplySort:
"""Numeric-aware, multi-key, blanks-last sorting."""
columns = {"name": 0, "amt": 1}
def test_numeric_ascending_keeps_blanks_last(self):
rows = [("c", "30"), ("a", "10"), ("b", ""), ("d", "20")]
out = asset_data._apply_sort(self.columns, rows, [_sort("amt", "asc")])
assert [r[1] for r in out] == ["10", "20", "30", ""]
def test_numeric_descending_keeps_blanks_last(self):
rows = [("c", "30"), ("a", "10"), ("b", ""), ("d", "20")]
out = asset_data._apply_sort(self.columns, rows, [_sort("amt", "desc")])
assert [r[1] for r in out] == ["30", "20", "10", ""]
def test_text_sort(self):
rows = [("banana", "1"), ("apple", "2"), ("", "3")]
out = asset_data._apply_sort(self.columns, rows, [_sort("name", "asc")])
assert [r[0] for r in out] == ["apple", "banana", ""]
def test_multi_key_sort(self):
rows = [("x", "b", "2"), ("x", "a", "1"), ("y", "a", "3")]
out = asset_data._apply_sort(
{"c0": 0, "c1": 1}, rows, [_sort("c0", "asc"), _sort("c1", "asc")]
)
assert out == [("x", "a", "1"), ("x", "b", "2"), ("y", "a", "3")]
def test_unknown_column_is_skipped(self):
rows = [("a", "1"), ("b", "2")]
out = asset_data._apply_sort(self.columns, rows, [_sort("ghost", "asc")])
assert out == rows
def test_empty_sort_model_returns_rows(self):
rows = [("a", "1")]
assert asset_data._apply_sort(self.columns, rows, []) is rows
class TestQuery:
"""Combined filter / sort / page for an SSRM request."""
columns = ["name", "amt"]
rows = [("c", "30"), ("a", "10"), ("b", "20")]
def test_sorts_and_pages(self):
request = _request(sort_model=[_sort("amt", "asc")], start=0, end=2)
page, total = asset_data.query(self.columns, self.rows, request)
assert total == 3
assert page == [{"name": "a", "amt": "10"}, {"name": "b", "amt": "20"}]
def test_non_positive_end_row_returns_all(self):
request = _request(start=0, end=0)
page, total = asset_data.query(self.columns, self.rows, request)
assert total == 3
assert len(page) == 3
def test_negative_start_row_clamped(self):
request = _request(start=-5, end=1)
page, _ = asset_data.query(self.columns, self.rows, request)
assert len(page) == 1
def test_filter_reduces_total(self):
request = _request(
filter_model={"name": _cond(filterType="text", type="equals", filter="a")}
)
page, total = asset_data.query(self.columns, self.rows, request)
assert total == 1
assert page == [{"name": "a", "amt": "10"}]