Files
OpenBB/openbb_platform/providers/nasdaq/tests/test_models_equity.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

558 lines
20 KiB
Python

"""Tests for the Nasdaq equity models."""
import asyncio
from datetime import date
import pytest
from openbb_core.app.model.abstract.error import OpenBBError
from openbb_core.provider.utils.errors import EmptyDataError
from openbb_nasdaq.models.company_news import NasdaqCompanyNewsFetcher
from openbb_nasdaq.models.crypto_historical import NasdaqCryptoHistoricalFetcher
from openbb_nasdaq.models.equity_historical import NasdaqEquityHistoricalFetcher
from openbb_nasdaq.models.equity_info import NasdaqEquityInfoFetcher
from openbb_nasdaq.models.equity_quote import (
NasdaqEquityQuoteFetcher,
_split_range,
)
from openbb_nasdaq.models.equity_search import NasdaqEquitySearchFetcher
from openbb_nasdaq.models.equity_short_interest import (
NasdaqEquityShortInterestFetcher,
)
from openbb_nasdaq.models.etf_historical import NasdaqEtfHistoricalFetcher
from openbb_nasdaq.models.index_historical import NasdaqIndexHistoricalFetcher
from .conftest import patch_asset_class, patch_data
SESSION = {
"date": date(2026, 7, 24),
"open": 1.0,
"high": 2.0,
"low": 0.5,
"close": 1.0,
"volume": 100.0,
}
class TestEquityQuote:
"""Cover the equity quote model."""
@staticmethod
def _payload():
"""Return an info and summary pair."""
return [
{
"symbol": "AAPL",
"info": {
"companyName": "Apple Inc. Common Stock",
"exchange": "NASDAQ-GS",
"stockType": "Common Stock",
"isNasdaq100": True,
"marketStatus": "Closed",
"primaryData": {
"lastSalePrice": "$333.02",
"netChange": "+11.36",
"percentageChange": "+3.53%",
"volume": "47,489,726",
"bidPrice": "$332.00",
"askPrice": "$334.00",
"bidSize": "100",
"askSize": "200",
"lastTradeTimestamp": "Jul 24, 2026",
},
},
"summary": {
"summaryData": {
"PreviousClose": {"value": "$321.66"},
"FiftTwoWeekHighLow": {"value": "$400.00/$200.00"},
"TodayHighLow": {"value": "$335.00 - $330.00"},
"Sector": {"value": "Technology"},
"Industry": {"value": "Computer Manufacturing"},
"MarketCap": {"value": "5,000,000"},
"AverageVolume": {"value": "50,000,000"},
"OneYrTarget": {"value": "$400.00"},
"AnnualizedDividend": {"value": "1.04"},
"Yield": {"value": "0.31%"},
"ExDividendDate": {"value": "Aug 8, 2026"},
"DividendPaymentDate": {"value": "Aug 15, 2026"},
}
},
}
]
def test_transforms_the_quote(self):
"""Every summary block field resolves."""
query = NasdaqEquityQuoteFetcher.transform_query({"symbol": "AAPL"})
rows = NasdaqEquityQuoteFetcher.transform_data(query, self._payload())
assert rows[0].name == "Apple Inc. Common Stock"
assert rows[0].last_price == pytest.approx(333.02)
assert rows[0].change_percent == pytest.approx(0.0353)
assert rows[0].year_high == 400
assert rows[0].year_low == 200
assert rows[0].high == 335
assert rows[0].low == 330
assert rows[0].dividend_yield == pytest.approx(0.0031)
assert rows[0].ex_dividend_date == date(2026, 8, 8)
assert rows[0].is_nasdaq_100 is True
def test_fetches_both_blocks(self, monkeypatch):
"""The info and summary endpoints are both requested."""
seen: list[str] = []
patch_data(monkeypatch, {"companyName": "Apple"}, seen)
query = NasdaqEquityQuoteFetcher.transform_query({"symbol": "aapl"})
raw = asyncio.run(NasdaqEquityQuoteFetcher.aextract_data(query, None))
assert len(seen) == 2
assert raw[0]["symbol"] == "AAPL"
def test_warns_on_a_bad_symbol(self, monkeypatch):
"""A symbol the endpoints reject warns and is skipped."""
async def _data(path, **kwargs):
raise OpenBBError("unknown symbol")
monkeypatch.setattr("openbb_nasdaq.utils.helpers.get_nasdaq_data", _data)
query = NasdaqEquityQuoteFetcher.transform_query({"symbol": "NOPE"})
with pytest.warns(UserWarning, match="NOPE"), pytest.raises(EmptyDataError):
asyncio.run(NasdaqEquityQuoteFetcher.aextract_data(query, None))
@pytest.mark.parametrize(
("value", "expected"),
[
("$400.00/$200.00", (400.0, 200.0)),
("$200.00 - $400.00", (400.0, 200.0)),
("no separator", (None, None)),
(None, (None, None)),
("$400.00/N/A", (None, None)),
],
)
def test_split_range(self, value, expected):
"""A display range splits into a high and a low."""
assert _split_range(value) == expected
class TestEquityInfo:
"""Cover the company profile model."""
def test_transforms_the_profile(self):
"""The profile block and quote header are merged."""
query = NasdaqEquityInfoFetcher.transform_query({"symbol": "AAPL"})
rows = NasdaqEquityInfoFetcher.transform_data(
query,
[
{
"symbol": "AAPL",
"profile": {
"CompanyName": {"value": "Apple Inc."},
"CompanyDescription": {"value": "Designs phones."},
"Industry": {"value": "Computer Manufacturing"},
"Sector": {"value": "Technology"},
"Region": {"value": "North America"},
"Address": {"value": "One Apple Park Way"},
"Phone": {"value": "408 996 1010"},
},
"info": {
"companyName": "Apple Inc. Common Stock",
"exchange": "NASDAQ-GS",
"stockType": "Common Stock",
"isNasdaq100": True,
},
}
],
)
assert rows[0].name == "Apple Inc."
assert rows[0].long_description == "Designs phones."
assert rows[0].sector == "Technology"
assert rows[0].exchange == "NASDAQ-GS"
def test_falls_back_to_the_quote_name(self):
"""A profile without a name uses the quote header."""
query = NasdaqEquityInfoFetcher.transform_query({"symbol": "AAPL"})
rows = NasdaqEquityInfoFetcher.transform_data(
query,
[{"symbol": "AAPL", "profile": {}, "info": {"companyName": "Apple Inc."}}],
)
assert rows[0].name == "Apple Inc."
class TestEquitySearch:
"""Cover the symbol directory search."""
def test_reads_the_directory(self, monkeypatch, directory_text):
"""The directory is downloaded and test issues are dropped."""
monkeypatch.setattr(
"openbb_nasdaq.utils.helpers.get_nasdaq_directory",
lambda *a: directory_text,
)
query = NasdaqEquitySearchFetcher.transform_query({})
raw = NasdaqEquitySearchFetcher.extract_data(query, None)
rows = NasdaqEquitySearchFetcher.transform_data(query, raw)
assert {r.symbol for r in rows} == {"AAPL"}
assert rows[0].cqs_symbol == "AAPL"
def test_filters_by_etf(self, directory_text):
"""The ETF flag narrows the directory in both directions."""
etfs = NasdaqEquitySearchFetcher.transform_data(
NasdaqEquitySearchFetcher.transform_query({"is_etf": True}), directory_text
)
stocks = NasdaqEquitySearchFetcher.transform_data(
NasdaqEquitySearchFetcher.transform_query({"is_etf": False}),
directory_text,
)
assert [r.symbol for r in etfs] == ["QQQ"]
assert [r.symbol for r in stocks] == ["AAPL"]
def test_filters_by_query(self, directory_text):
"""A query matches the symbol or the security name."""
rows = NasdaqEquitySearchFetcher.transform_data(
NasdaqEquitySearchFetcher.transform_query({"query": "apple"}),
directory_text,
)
assert [r.symbol for r in rows] == ["AAPL"]
class TestEquityShortInterest:
"""Cover the short interest model."""
def test_derives_the_change(self):
"""The change and percent change are derived from the prior settlement."""
query = NasdaqEquityShortInterestFetcher.transform_query({"symbol": "AAPL"})
rows = NasdaqEquityShortInterestFetcher.transform_data(
query,
[
{
"symbol": "AAPL",
"settlementDate": "07/15/2026",
"interest": "200",
"avgDailyShareVolume": "50",
"daysToCover": "4",
},
{
"symbol": "AAPL",
"settlementDate": "06/30/2026",
"interest": "100",
"avgDailyShareVolume": "50",
"daysToCover": "2",
},
],
)
assert rows[0].settlement_date == date(2026, 7, 15)
assert rows[0].previous_short_position == 100
assert rows[0].change == 100
assert rows[0].change_pct == pytest.approx(1.0)
assert rows[1].change is None
def test_first_settlement_has_no_change(self):
"""The oldest settlement has nothing to compare against."""
query = NasdaqEquityShortInterestFetcher.transform_query({"symbol": "AAPL"})
rows = NasdaqEquityShortInterestFetcher.transform_data(
query,
[
{
"symbol": "AAPL",
"settlementDate": "06/30/2026",
"interest": "0",
"avgDailyShareVolume": "50",
"daysToCover": "2",
},
{
"symbol": "AAPL",
"settlementDate": "07/15/2026",
"interest": "100",
"avgDailyShareVolume": "50",
"daysToCover": "4",
},
],
)
assert rows[-1].previous_short_position is None
assert rows[0].change == 100
assert rows[0].change_pct is None
class TestCompanyNews:
"""Cover the company news model."""
@staticmethod
def _rows():
"""Return two articles, one duplicated."""
return [
{
"url": "/articles/a",
"created": "Jul 25, 2026",
"title": "Chip sell-off",
"description": "Semis fall.",
"publisher": "Barchart",
"primarytopic": "Markets|topic",
"image": "https://img/a.png",
"related_symbols": ["spy|etf", "qqq|etf"],
"requested_symbol": "SPY",
},
{
"url": "/articles/a",
"created": "Jul 25, 2026",
"title": "Duplicate",
"requested_symbol": "SPY",
},
{
"url": "https://x/b",
"created": "N/A",
"title": "Undated",
"requested_symbol": "SPY",
},
]
def test_deduplicates_and_qualifies_urls(self):
"""Repeat and undated articles drop, and relative URLs are qualified."""
query = NasdaqCompanyNewsFetcher.transform_query({"symbol": "SPY"})
rows = NasdaqCompanyNewsFetcher.transform_data(query, self._rows())
assert len(rows) == 1
assert rows[0].url == "https://www.nasdaq.com/articles/a"
assert rows[0].symbols == "SPY,QQQ"
assert rows[0].topic == "Markets"
assert rows[0].images[0]["url"] == "https://img/a.png"
def test_falls_back_to_the_requested_symbol(self):
"""An article without related symbols is tagged with the request."""
query = NasdaqCompanyNewsFetcher.transform_query({"symbol": "SPY"})
rows = NasdaqCompanyNewsFetcher.transform_data(
query,
[
{
"url": "/a",
"created": "Jul 25, 2026",
"title": "Untagged",
"requested_symbol": "SPY",
}
],
)
assert rows[0].symbols == "SPY"
def test_filters_by_window(self):
"""Articles outside the requested window are dropped."""
query = NasdaqCompanyNewsFetcher.transform_query(
{"symbol": "SPY", "start_date": date(2026, 8, 1)}
)
assert NasdaqCompanyNewsFetcher.transform_data(query, self._rows()) == []
query = NasdaqCompanyNewsFetcher.transform_query(
{"symbol": "SPY", "end_date": date(2026, 1, 1)}
)
assert NasdaqCompanyNewsFetcher.transform_data(query, self._rows()) == []
def test_requests_the_resolved_asset_class(self, monkeypatch):
"""The asset class is resolved per symbol and the fallback is requested."""
seen: list[str] = []
patch_asset_class(monkeypatch, "etf")
patch_data(monkeypatch, {"rows": [{"url": "/a"}]}, seen)
query = NasdaqCompanyNewsFetcher.transform_query({"symbol": "spy"})
raw = asyncio.run(NasdaqCompanyNewsFetcher.aextract_data(query, None))
assert "q=SPY%7Cetf" in seen[0]
assert "fallback=true" in seen[0]
assert raw[0]["requested_symbol"] == "SPY"
def test_warns_on_a_failing_symbol(self, monkeypatch):
"""A symbol with no news warns and is skipped."""
patch_asset_class(monkeypatch, "stocks")
async def _data(path, **kwargs):
raise OpenBBError("no news")
monkeypatch.setattr("openbb_nasdaq.utils.helpers.get_nasdaq_data", _data)
query = NasdaqCompanyNewsFetcher.transform_query({"symbol": "NOPE"})
with pytest.warns(UserWarning, match="NOPE"), pytest.raises(EmptyDataError):
asyncio.run(NasdaqCompanyNewsFetcher.aextract_data(query, None))
class TestPriceHistoryModels:
"""Cover the models that delegate to the shared price helper."""
@pytest.mark.parametrize(
("fetcher", "asset_class"),
[
(NasdaqEquityHistoricalFetcher, "stocks"),
(NasdaqEtfHistoricalFetcher, None),
(NasdaqCryptoHistoricalFetcher, "crypto"),
],
ids=["equity", "etf", "crypto"],
)
def test_delegates_with_its_asset_class(self, fetcher, asset_class, monkeypatch):
"""Each model requests its own asset class; funds resolve per symbol."""
seen: list[tuple] = []
async def _gather(symbols, klass, start, end):
seen.append((symbols, klass))
return [SESSION]
monkeypatch.setattr(
"openbb_nasdaq.utils.helpers.gather_historical_prices", _gather
)
query = fetcher.transform_query({"symbol": "X"})
raw = asyncio.run(fetcher.aextract_data(query, None))
rows = fetcher.transform_data(query, raw)
assert seen[0][1] == asset_class
assert rows[0].close == 1.0
class TestIndexHistorical:
"""Cover the index history model and its intraday interval."""
def test_daily_delegates(self, monkeypatch):
"""The daily interval uses the shared price helper."""
seen: list[tuple] = []
async def _gather(symbols, klass, start, end):
seen.append((symbols, klass))
return [SESSION]
monkeypatch.setattr(
"openbb_nasdaq.utils.helpers.gather_historical_prices", _gather
)
query = NasdaqIndexHistoricalFetcher.transform_query({"symbol": "COMP"})
raw = asyncio.run(NasdaqIndexHistoricalFetcher.aextract_data(query, None))
rows = NasdaqIndexHistoricalFetcher.transform_data(query, raw)
assert seen[0][1] == "index"
assert rows[0].close == 1.0
def test_intraday_reads_the_chart(self, monkeypatch):
"""The one-minute interval reads the chart series."""
seen: list[str] = []
patch_data(
monkeypatch,
[
{
"symbol": "COMP",
"indexCharts": [
{
"z": {"dateTime": "7/24/2026 9:30:58 AM"},
"y": 25120.13,
},
{"z": {"dateTime": "bad"}, "y": 1},
],
}
],
seen,
)
query = NasdaqIndexHistoricalFetcher.transform_query(
{"symbol": "COMP", "interval": "1m"}
)
raw = asyncio.run(NasdaqIndexHistoricalFetcher.aextract_data(query, None))
rows = NasdaqIndexHistoricalFetcher.transform_data(query, raw)
assert "chartFor=COMP" in seen[0]
assert len(rows) == 1
assert rows[0].date.hour == 9
assert rows[0].close == pytest.approx(25120.13)
def test_intraday_without_a_series(self, monkeypatch):
"""An index with no session series is reported."""
patch_data(monkeypatch, None)
query = NasdaqIndexHistoricalFetcher.transform_query(
{"symbol": "COMP", "interval": "1m"}
)
raw = asyncio.run(NasdaqIndexHistoricalFetcher.aextract_data(query, None))
with pytest.raises(EmptyDataError, match="No intraday series"):
NasdaqIndexHistoricalFetcher.transform_data(query, raw)
class TestEquityInfoExtract:
"""Cover the company profile request."""
def test_fetches_both_blocks(self, monkeypatch):
"""The profile and quote header are both requested."""
seen: list[str] = []
patch_data(
monkeypatch,
lambda path: (
{"companyName": "Apple Inc."}
if "/info" in path
else {"CompanyName": {"value": "Apple Inc."}}
),
seen,
)
query = NasdaqEquityInfoFetcher.transform_query({"symbol": "aapl"})
raw = asyncio.run(NasdaqEquityInfoFetcher.aextract_data(query, None))
assert len(seen) == 2
assert raw[0]["symbol"] == "AAPL"
def test_warns_on_a_bad_symbol(self, monkeypatch):
"""A symbol with no profile warns and is skipped."""
async def _data(path, **kwargs):
raise OpenBBError("unknown symbol")
monkeypatch.setattr("openbb_nasdaq.utils.helpers.get_nasdaq_data", _data)
query = NasdaqEquityInfoFetcher.transform_query({"symbol": "NOPE"})
with (
pytest.warns(UserWarning, match="NOPE"),
pytest.raises(EmptyDataError, match="No company profiles"),
):
asyncio.run(NasdaqEquityInfoFetcher.aextract_data(query, None))
class TestShortInterestExtract:
"""Cover the short interest request."""
def test_labels_each_row(self, monkeypatch):
"""Each settlement row is tagged with its symbol."""
patch_data(
monkeypatch,
{
"shortInterestTable": {
"rows": [{"settlementDate": "07/15/2026", "interest": "100"}]
}
},
)
query = NasdaqEquityShortInterestFetcher.transform_query({"symbol": "aapl"})
raw = asyncio.run(NasdaqEquityShortInterestFetcher.aextract_data(query, None))
assert raw[0]["symbol"] == "AAPL"
def test_empty_raises(self, monkeypatch):
"""A symbol with no short interest is reported."""
patch_data(monkeypatch, {})
query = NasdaqEquityShortInterestFetcher.transform_query({"symbol": "AAPL"})
with pytest.raises(EmptyDataError):
asyncio.run(NasdaqEquityShortInterestFetcher.aextract_data(query, None))
class TestParserGuards:
"""Cover the guards for unparseable upstream values."""
@pytest.mark.parametrize("value", [None, "", " ", 42])
def test_index_chart_time(self, value):
"""Anything that is not a chart timestamp yields None."""
from openbb_nasdaq.models.index_historical import parse_chart_time
assert parse_chart_time(value) is None
def test_dividend_amount_placeholder(self):
"""A dividend amount of N/A yields None before validation."""
from openbb_nasdaq.models.historical_dividends import (
NasdaqHistoricalDividendsData,
)
assert NasdaqHistoricalDividendsData.validate_amount("N/A") is None