mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-11-08 10:22:09 +00:00
* 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 <>
558 lines
20 KiB
Python
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
|