"""Offline integration tests for the Finnhub dataflow modules. All HTTP calls are patched with unittest.mock so no real network requests are made and no FINNHUB_API_KEY is required. Mock responses reproduce realistic Finnhub response shapes to exercise every significant code path. Run with: pytest tests/test_finnhub_integration.py -v """ import json import os import time from unittest.mock import MagicMock, call, patch import pytest import requests # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) def set_fake_api_key(monkeypatch): """Inject a dummy API key so every test bypasses the missing-key guard.""" monkeypatch.setenv("FINNHUB_API_KEY", "test_key") # --------------------------------------------------------------------------- # Shared mock-response helpers # --------------------------------------------------------------------------- def _json_response(payload: dict | list, status_code: int = 200) -> MagicMock: """Return a mock requests.Response whose .json() returns *payload*.""" resp = MagicMock() resp.status_code = status_code resp.text = json.dumps(payload) resp.json.return_value = payload resp.raise_for_status = MagicMock() return resp def _error_response(status_code: int, body: str = "") -> MagicMock: """Return a mock response with a non-2xx status code.""" resp = MagicMock() resp.status_code = status_code resp.text = body resp.json.side_effect = ValueError("not json") resp.raise_for_status.side_effect = requests.HTTPError(f"{status_code}") return resp # --------------------------------------------------------------------------- # Canned response payloads # --------------------------------------------------------------------------- CANDLE_OK = { "s": "ok", "t": [1704067200, 1704153600, 1704240000], # 2024-01-01 .. 2024-01-03 "o": [185.0, 186.0, 187.0], "h": [188.0, 189.0, 190.0], "l": [184.0, 185.0, 186.0], "c": [187.0, 188.0, 189.0], "v": [50_000_000, 45_000_000, 48_000_000], } CANDLE_NO_DATA = {"s": "no_data"} CANDLE_ERROR_STATUS = {"s": "error"} QUOTE_OK = { "c": 189.5, "d": 1.5, "dp": 0.8, "h": 191.0, "l": 187.0, "o": 188.0, "pc": 188.0, "t": 1704153600, } QUOTE_ALL_ZERO = {"c": 0.0, "d": 0.0, "dp": 0.0, "h": 0.0, "l": 0.0, "o": 0.0, "pc": 0.0, "t": 0} PROFILE_OK = { "name": "Apple Inc", "ticker": "AAPL", "exchange": "NASDAQ/NMS (GLOBAL MARKET)", "ipo": "1980-12-12", "finnhubIndustry": "Technology", "marketCapitalization": 2_900_000.0, "shareOutstanding": 15_500.0, "currency": "USD", "country": "US", "weburl": "https://www.apple.com/", "logo": "https://static.finnhub.io/logo/87cb30d8-80df-11ea-8951-00000000092a.png", "phone": "14089961010", } FINANCIALS_OK = { "cik": "0000320193", "data": [ { "period": "2023-12-30", "year": 2023, "quarter": 1, "filedDate": "2024-02-02", "acceptedDate": "2024-02-02", "form": "10-Q", "cik": "0000320193", "report": { "ic": [ { "concept": "us-gaap:Revenues", "label": "Revenues", "unit": "USD", "value": 119_575_000_000, }, { "concept": "us-gaap:NetIncomeLoss", "label": "Net Income", "unit": "USD", "value": 33_916_000_000, }, ], "bs": [], "cf": [], }, } ], } FINANCIALS_EMPTY = {"data": []} METRIC_OK = { "metric": { "peTTM": 28.5, "peAnnual": 29.1, "pbQuarterly": 45.2, "pbAnnual": 46.0, "psTTM": 7.3, "52WeekHigh": 199.0, "52WeekLow": 124.0, "roeTTM": 147.0, "roaTTM": 28.0, "epsTTM": 6.42, "dividendYieldIndicatedAnnual": 0.54, "beta": 1.25, }, "series": {}, } METRIC_EMPTY = {"metric": {}} COMPANY_NEWS_OK = [ { "headline": "Apple Unveils New iPhone Model", "source": "Reuters", "summary": "Apple announced its latest device lineup at an event in Cupertino.", "url": "https://example.com/news/apple-iphone", "datetime": 1704153600, "category": "technology", "sentiment": 0.4, } ] MARKET_NEWS_OK = [ { "headline": "Fed Signals Rate Pause Ahead", "source": "Bloomberg", "summary": "Federal Reserve officials indicated they may hold rates steady.", "url": "https://example.com/news/fed", "datetime": 1704153600, } ] INSIDER_TXN_OK = { "data": [ { "name": "Tim Cook", "transactionCode": "S", "share": 100_000, "price": 185.5, "value": 18_550_000.0, "transactionDate": "2024-01-10", "filingDate": "2024-01-12", } ] } INSIDER_TXN_EMPTY = {"data": []} INDICATOR_RSI_OK = { "s": "ok", "t": [1704067200, 1704153600], "rsi": [62.5, 64.1], } INDICATOR_MACD_OK = { "s": "ok", "t": [1704067200, 1704153600], "macd": [1.23, 1.45], "macdSignal": [1.10, 1.30], "macdHist": [0.13, 0.15], } INDICATOR_BBANDS_OK = { "s": "ok", "t": [1704067200, 1704153600], "upperBand": [195.0, 196.0], "middleBand": [185.0, 186.0], "lowerBand": [175.0, 176.0], } INDICATOR_NO_DATA = {"s": "no_data", "t": []} # --------------------------------------------------------------------------- # 1. finnhub_common — Exception hierarchy # --------------------------------------------------------------------------- class TestFinnhubExceptionHierarchy: """All custom exceptions must be proper subclasses of FinnhubError.""" def test_finnhub_error_is_exception(self): from tradingagents.dataflows.finnhub_common import FinnhubError assert issubclass(FinnhubError, Exception) def test_api_key_invalid_error_is_finnhub_error(self): from tradingagents.dataflows.finnhub_common import APIKeyInvalidError, FinnhubError assert issubclass(APIKeyInvalidError, FinnhubError) def test_rate_limit_error_is_finnhub_error(self): from tradingagents.dataflows.finnhub_common import FinnhubError, RateLimitError assert issubclass(RateLimitError, FinnhubError) def test_third_party_error_is_finnhub_error(self): from tradingagents.dataflows.finnhub_common import FinnhubError, ThirdPartyError assert issubclass(ThirdPartyError, FinnhubError) def test_third_party_timeout_error_is_finnhub_error(self): from tradingagents.dataflows.finnhub_common import FinnhubError, ThirdPartyTimeoutError assert issubclass(ThirdPartyTimeoutError, FinnhubError) def test_third_party_parse_error_is_finnhub_error(self): from tradingagents.dataflows.finnhub_common import FinnhubError, ThirdPartyParseError assert issubclass(ThirdPartyParseError, FinnhubError) def test_all_exceptions_can_be_raised_and_caught(self): from tradingagents.dataflows.finnhub_common import ( APIKeyInvalidError, FinnhubError, RateLimitError, ThirdPartyError, ThirdPartyParseError, ThirdPartyTimeoutError, ) for exc_class in ( APIKeyInvalidError, RateLimitError, ThirdPartyError, ThirdPartyTimeoutError, ThirdPartyParseError, ): with pytest.raises(FinnhubError): raise exc_class("test message") # --------------------------------------------------------------------------- # 2. finnhub_common — get_api_key # --------------------------------------------------------------------------- class TestGetApiKey: """get_api_key() reads from env; raises APIKeyInvalidError when absent.""" def test_returns_key_when_set(self): from tradingagents.dataflows.finnhub_common import get_api_key # autouse fixture already sets FINNHUB_API_KEY=test_key assert get_api_key() == "test_key" def test_raises_when_env_var_missing(self, monkeypatch): from tradingagents.dataflows.finnhub_common import APIKeyInvalidError, get_api_key monkeypatch.delenv("FINNHUB_API_KEY", raising=False) with pytest.raises(APIKeyInvalidError, match="FINNHUB_API_KEY"): get_api_key() def test_raises_when_env_var_empty_string(self, monkeypatch): from tradingagents.dataflows.finnhub_common import APIKeyInvalidError, get_api_key monkeypatch.setenv("FINNHUB_API_KEY", "") with pytest.raises(APIKeyInvalidError): get_api_key() # --------------------------------------------------------------------------- # 3. finnhub_common — _make_api_request HTTP status mapping # --------------------------------------------------------------------------- class TestMakeApiRequest: """_make_api_request maps HTTP status codes to the correct exceptions.""" _PATCH_TARGET = "tradingagents.dataflows.finnhub_common.requests.get" def test_success_returns_dict(self): from tradingagents.dataflows.finnhub_common import _make_api_request with patch(self._PATCH_TARGET, return_value=_json_response({"foo": "bar"})): result = _make_api_request("quote", {"symbol": "AAPL"}) assert result == {"foo": "bar"} def test_http_401_raises_api_key_invalid_error(self): from tradingagents.dataflows.finnhub_common import APIKeyInvalidError, _make_api_request with patch(self._PATCH_TARGET, return_value=_error_response(401, "Unauthorized")): with pytest.raises(APIKeyInvalidError): _make_api_request("quote", {"symbol": "AAPL"}) def test_http_403_raises_api_key_invalid_error(self): from tradingagents.dataflows.finnhub_common import APIKeyInvalidError, _make_api_request with patch(self._PATCH_TARGET, return_value=_error_response(403, "Forbidden")): with pytest.raises(APIKeyInvalidError): _make_api_request("quote", {"symbol": "AAPL"}) def test_http_429_raises_rate_limit_error(self): from tradingagents.dataflows.finnhub_common import RateLimitError, _make_api_request with patch(self._PATCH_TARGET, return_value=_error_response(429, "Too Many Requests")): with pytest.raises(RateLimitError): _make_api_request("quote", {"symbol": "AAPL"}) def test_http_500_raises_third_party_error(self): from tradingagents.dataflows.finnhub_common import ThirdPartyError, _make_api_request with patch(self._PATCH_TARGET, return_value=_error_response(500, "Internal Server Error")): with pytest.raises(ThirdPartyError): _make_api_request("quote", {"symbol": "AAPL"}) def test_timeout_raises_third_party_timeout_error(self): from tradingagents.dataflows.finnhub_common import ( ThirdPartyTimeoutError, _make_api_request, ) with patch( self._PATCH_TARGET, side_effect=requests.exceptions.Timeout("timed out") ): with pytest.raises(ThirdPartyTimeoutError): _make_api_request("quote", {"symbol": "AAPL"}) def test_connection_error_raises_third_party_error(self): from tradingagents.dataflows.finnhub_common import ThirdPartyError, _make_api_request with patch( self._PATCH_TARGET, side_effect=requests.exceptions.ConnectionError("connection refused"), ): with pytest.raises(ThirdPartyError): _make_api_request("quote", {"symbol": "AAPL"}) def test_bad_json_raises_third_party_parse_error(self): from tradingagents.dataflows.finnhub_common import ( ThirdPartyParseError, _make_api_request, ) bad_resp = MagicMock() bad_resp.status_code = 200 bad_resp.text = "not-json!!" bad_resp.json.side_effect = ValueError("invalid json") bad_resp.raise_for_status = MagicMock() with patch(self._PATCH_TARGET, return_value=bad_resp): with pytest.raises(ThirdPartyParseError): _make_api_request("quote", {"symbol": "AAPL"}) def test_token_is_injected_into_request_params(self): """The API key must be passed as 'token' in the query params.""" from tradingagents.dataflows.finnhub_common import _make_api_request captured = {} def capture(url, params, **kwargs): captured.update(params) return _json_response({}) with patch(self._PATCH_TARGET, side_effect=capture): _make_api_request("quote", {"symbol": "AAPL"}) assert captured.get("token") == "test_key" # --------------------------------------------------------------------------- # 4. finnhub_common — utility helpers # --------------------------------------------------------------------------- class TestToUnixTimestamp: """_to_unix_timestamp converts YYYY-MM-DD strings to integer Unix timestamps.""" def test_known_date_returns_integer(self): from tradingagents.dataflows.finnhub_common import _to_unix_timestamp result = _to_unix_timestamp("2024-01-15") assert isinstance(result, int) # 2024-01-15 00:00 UTC+0 is 1705276800; local TZ may shift ±hours but # the date portion is always in range [1705190400, 1705363200] assert 1705190400 <= result <= 1705363200 def test_invalid_format_raises_value_error(self): from tradingagents.dataflows.finnhub_common import _to_unix_timestamp with pytest.raises(ValueError): _to_unix_timestamp("15-01-2024") def test_non_date_string_raises_value_error(self): from tradingagents.dataflows.finnhub_common import _to_unix_timestamp with pytest.raises(ValueError): _to_unix_timestamp("not-a-date") class TestFmtPct: """_fmt_pct formats floats as signed percentage strings.""" def test_positive_float(self): from tradingagents.dataflows.finnhub_common import _fmt_pct assert _fmt_pct(1.23) == "+1.23%" def test_negative_float(self): from tradingagents.dataflows.finnhub_common import _fmt_pct assert _fmt_pct(-4.56) == "-4.56%" def test_zero(self): from tradingagents.dataflows.finnhub_common import _fmt_pct assert _fmt_pct(0.0) == "+0.00%" def test_none_returns_na(self): from tradingagents.dataflows.finnhub_common import _fmt_pct assert _fmt_pct(None) == "N/A" # --------------------------------------------------------------------------- # 5. finnhub_stock — get_stock_candles # --------------------------------------------------------------------------- class TestGetStockCandles: """get_stock_candles returns a CSV string or raises FinnhubError.""" _PATCH_TARGET = "tradingagents.dataflows.finnhub_common.requests.get" def test_ok_response_produces_csv_with_header(self): from tradingagents.dataflows.finnhub_stock import get_stock_candles with patch(self._PATCH_TARGET, return_value=_json_response(CANDLE_OK)): result = get_stock_candles("AAPL", "2024-01-01", "2024-01-03") lines = result.strip().split("\n") assert lines[0] == "timestamp,open,high,low,close,volume" assert len(lines) >= 2, "Expected at least one data row" def test_ok_response_data_rows_contain_price(self): from tradingagents.dataflows.finnhub_stock import get_stock_candles with patch(self._PATCH_TARGET, return_value=_json_response(CANDLE_OK)): result = get_stock_candles("AAPL", "2024-01-01", "2024-01-03") # Each data row should have 6 comma-separated fields data_rows = result.strip().split("\n")[1:] for row in data_rows: fields = row.split(",") assert len(fields) == 6 def test_no_data_status_raises_finnhub_error(self): from tradingagents.dataflows.finnhub_common import FinnhubError from tradingagents.dataflows.finnhub_stock import get_stock_candles with patch(self._PATCH_TARGET, return_value=_json_response(CANDLE_NO_DATA)): with pytest.raises(FinnhubError): get_stock_candles("INVALID", "2024-01-01", "2024-01-03") def test_error_status_raises_finnhub_error(self): from tradingagents.dataflows.finnhub_common import FinnhubError from tradingagents.dataflows.finnhub_stock import get_stock_candles with patch(self._PATCH_TARGET, return_value=_json_response(CANDLE_ERROR_STATUS)): with pytest.raises(FinnhubError): get_stock_candles("AAPL", "2024-01-01", "2024-01-03") def test_ok_with_empty_timestamps_raises_finnhub_error(self): from tradingagents.dataflows.finnhub_common import FinnhubError from tradingagents.dataflows.finnhub_stock import get_stock_candles empty_candle = {"s": "ok", "t": [], "o": [], "h": [], "l": [], "c": [], "v": []} with patch(self._PATCH_TARGET, return_value=_json_response(empty_candle)): with pytest.raises(FinnhubError): get_stock_candles("AAPL", "2024-01-01", "2024-01-03") # --------------------------------------------------------------------------- # 6. finnhub_stock — get_quote # --------------------------------------------------------------------------- class TestGetQuote: """get_quote returns a normalised dict or raises FinnhubError.""" _PATCH_TARGET = "tradingagents.dataflows.finnhub_common.requests.get" def test_ok_response_returns_expected_keys(self): from tradingagents.dataflows.finnhub_stock import get_quote with patch(self._PATCH_TARGET, return_value=_json_response(QUOTE_OK)): result = get_quote("AAPL") expected_keys = { "symbol", "current_price", "change", "change_percent", "high", "low", "open", "prev_close", "timestamp", } assert expected_keys == set(result.keys()) def test_ok_response_symbol_field_is_correct(self): from tradingagents.dataflows.finnhub_stock import get_quote with patch(self._PATCH_TARGET, return_value=_json_response(QUOTE_OK)): result = get_quote("AAPL") assert result["symbol"] == "AAPL" def test_ok_response_prices_are_floats(self): from tradingagents.dataflows.finnhub_stock import get_quote with patch(self._PATCH_TARGET, return_value=_json_response(QUOTE_OK)): result = get_quote("AAPL") assert isinstance(result["current_price"], float) assert isinstance(result["change_percent"], float) def test_all_zero_response_raises_finnhub_error(self): from tradingagents.dataflows.finnhub_common import FinnhubError from tradingagents.dataflows.finnhub_stock import get_quote with patch(self._PATCH_TARGET, return_value=_json_response(QUOTE_ALL_ZERO)): with pytest.raises(FinnhubError, match="all-zero"): get_quote("BADINVLDSYM") def test_timestamp_absent_uses_now_string(self): """When t=0 (no timestamp), the fallback is a formatted 'now' string.""" from tradingagents.dataflows.finnhub_stock import get_quote quote_no_ts = dict(QUOTE_OK) quote_no_ts["t"] = 0 with patch(self._PATCH_TARGET, return_value=_json_response(quote_no_ts)): result = get_quote("AAPL") # Timestamp must be a non-empty string assert isinstance(result["timestamp"], str) assert result["timestamp"] # --------------------------------------------------------------------------- # 7. finnhub_fundamentals — get_company_profile # --------------------------------------------------------------------------- class TestGetCompanyProfile: """get_company_profile returns a formatted string or raises FinnhubError.""" _PATCH_TARGET = "tradingagents.dataflows.finnhub_common.requests.get" def test_ok_response_contains_company_name(self): from tradingagents.dataflows.finnhub_fundamentals import get_company_profile with patch(self._PATCH_TARGET, return_value=_json_response(PROFILE_OK)): result = get_company_profile("AAPL") assert "Apple Inc" in result def test_ok_response_contains_symbol(self): from tradingagents.dataflows.finnhub_fundamentals import get_company_profile with patch(self._PATCH_TARGET, return_value=_json_response(PROFILE_OK)): result = get_company_profile("AAPL") assert "AAPL" in result def test_ok_response_contains_exchange(self): from tradingagents.dataflows.finnhub_fundamentals import get_company_profile with patch(self._PATCH_TARGET, return_value=_json_response(PROFILE_OK)): result = get_company_profile("AAPL") assert "NASDAQ" in result def test_empty_profile_raises_finnhub_error(self): from tradingagents.dataflows.finnhub_common import FinnhubError from tradingagents.dataflows.finnhub_fundamentals import get_company_profile with patch(self._PATCH_TARGET, return_value=_json_response({})): with pytest.raises(FinnhubError): get_company_profile("BADINVLDSYM") def test_result_is_multiline_string(self): from tradingagents.dataflows.finnhub_fundamentals import get_company_profile with patch(self._PATCH_TARGET, return_value=_json_response(PROFILE_OK)): result = get_company_profile("AAPL") assert "\n" in result # --------------------------------------------------------------------------- # 8. finnhub_fundamentals — get_financial_statements # --------------------------------------------------------------------------- class TestGetFinancialStatements: """get_financial_statements returns formatted text or raises on errors.""" _PATCH_TARGET = "tradingagents.dataflows.finnhub_common.requests.get" def test_income_statement_ok_has_header(self): from tradingagents.dataflows.finnhub_fundamentals import get_financial_statements with patch(self._PATCH_TARGET, return_value=_json_response(FINANCIALS_OK)): result = get_financial_statements("AAPL", "income_statement", "quarterly") # Header should mention the statement type and symbol assert "AAPL" in result assert "Income Statement" in result or "income_statement" in result.lower() def test_income_statement_ok_contains_line_items(self): from tradingagents.dataflows.finnhub_fundamentals import get_financial_statements with patch(self._PATCH_TARGET, return_value=_json_response(FINANCIALS_OK)): result = get_financial_statements("AAPL", "income_statement", "quarterly") assert "Revenues" in result or "Net Income" in result def test_empty_data_list_raises_finnhub_error(self): from tradingagents.dataflows.finnhub_common import FinnhubError from tradingagents.dataflows.finnhub_fundamentals import get_financial_statements with patch(self._PATCH_TARGET, return_value=_json_response(FINANCIALS_EMPTY)): with pytest.raises(FinnhubError, match="No financial reports"): get_financial_statements("AAPL", "income_statement", "quarterly") def test_invalid_statement_type_raises_value_error(self): from tradingagents.dataflows.finnhub_fundamentals import get_financial_statements with pytest.raises(ValueError, match="Invalid statement_type"): get_financial_statements("AAPL", "invalid_type", "quarterly") # type: ignore[arg-type] def test_balance_sheet_and_cash_flow_accepted(self): """Both 'balance_sheet' and 'cash_flow' are valid statement_type values.""" from tradingagents.dataflows.finnhub_fundamentals import get_financial_statements # Build a payload with bs and cf data present bs_payload = { "data": [ { "period": "2023-12-30", "year": 2023, "quarter": 1, "filedDate": "2024-02-02", "acceptedDate": "2024-02-02", "form": "10-Q", "cik": "0000320193", "report": { "bs": [{"concept": "us-gaap:Assets", "label": "Assets", "unit": "USD", "value": 352_583_000_000}], "ic": [], "cf": [], }, } ] } with patch(self._PATCH_TARGET, return_value=_json_response(bs_payload)): result = get_financial_statements("AAPL", "balance_sheet", "annual") assert "AAPL" in result # --------------------------------------------------------------------------- # 9. finnhub_fundamentals — get_basic_financials # --------------------------------------------------------------------------- class TestGetBasicFinancials: """get_basic_financials returns formatted metrics or raises FinnhubError.""" _PATCH_TARGET = "tradingagents.dataflows.finnhub_common.requests.get" def test_ok_response_contains_valuation_header(self): from tradingagents.dataflows.finnhub_fundamentals import get_basic_financials with patch(self._PATCH_TARGET, return_value=_json_response(METRIC_OK)): result = get_basic_financials("AAPL") assert "Valuation" in result def test_ok_response_contains_symbol(self): from tradingagents.dataflows.finnhub_fundamentals import get_basic_financials with patch(self._PATCH_TARGET, return_value=_json_response(METRIC_OK)): result = get_basic_financials("AAPL") assert "AAPL" in result def test_ok_response_has_pe_metric(self): from tradingagents.dataflows.finnhub_fundamentals import get_basic_financials with patch(self._PATCH_TARGET, return_value=_json_response(METRIC_OK)): result = get_basic_financials("AAPL") assert "P/E" in result def test_empty_metric_raises_finnhub_error(self): from tradingagents.dataflows.finnhub_common import FinnhubError from tradingagents.dataflows.finnhub_fundamentals import get_basic_financials with patch(self._PATCH_TARGET, return_value=_json_response(METRIC_EMPTY)): with pytest.raises(FinnhubError): get_basic_financials("BADINVLDSYM") def test_missing_optional_metrics_rendered_as_na(self): """Metrics absent from the payload should appear as 'N/A' in output.""" from tradingagents.dataflows.finnhub_fundamentals import get_basic_financials sparse_metric = {"metric": {"peTTM": 25.0}} # all others absent with patch(self._PATCH_TARGET, return_value=_json_response(sparse_metric)): result = get_basic_financials("AAPL") assert "N/A" in result # --------------------------------------------------------------------------- # 10. finnhub_news — get_company_news # --------------------------------------------------------------------------- class TestGetCompanyNews: """get_company_news returns formatted markdown or 'no news' fallback.""" _PATCH_TARGET = "tradingagents.dataflows.finnhub_common.requests.get" def test_ok_response_contains_headline(self): from tradingagents.dataflows.finnhub_news import get_company_news with patch(self._PATCH_TARGET, return_value=_json_response(COMPANY_NEWS_OK)): result = get_company_news("AAPL", "2024-01-01", "2024-01-10") assert "Apple Unveils New iPhone Model" in result def test_ok_response_contains_source(self): from tradingagents.dataflows.finnhub_news import get_company_news with patch(self._PATCH_TARGET, return_value=_json_response(COMPANY_NEWS_OK)): result = get_company_news("AAPL", "2024-01-01", "2024-01-10") assert "Reuters" in result def test_empty_articles_list_returns_no_news_message(self): from tradingagents.dataflows.finnhub_news import get_company_news with patch(self._PATCH_TARGET, return_value=_json_response([])): result = get_company_news("AAPL", "2024-01-01", "2024-01-10") assert "No news articles" in result def test_result_has_symbol_in_header(self): from tradingagents.dataflows.finnhub_news import get_company_news with patch(self._PATCH_TARGET, return_value=_json_response(COMPANY_NEWS_OK)): result = get_company_news("AAPL", "2024-01-01", "2024-01-10") assert "AAPL" in result # --------------------------------------------------------------------------- # 11. finnhub_news — get_market_news # --------------------------------------------------------------------------- class TestGetMarketNews: """get_market_news returns formatted news or raises on invalid categories.""" _PATCH_TARGET = "tradingagents.dataflows.finnhub_common.requests.get" def test_general_category_contains_market_news_header(self): from tradingagents.dataflows.finnhub_news import get_market_news with patch(self._PATCH_TARGET, return_value=_json_response(MARKET_NEWS_OK)): result = get_market_news("general") assert "Market News" in result def test_valid_categories_accepted(self): """All four valid categories should not raise ValueError.""" from tradingagents.dataflows.finnhub_news import get_market_news for category in ("general", "forex", "crypto", "merger"): with patch(self._PATCH_TARGET, return_value=_json_response([])): result = get_market_news(category) # should not raise assert isinstance(result, str) def test_invalid_category_raises_value_error(self): from tradingagents.dataflows.finnhub_news import get_market_news with pytest.raises(ValueError, match="Invalid category"): get_market_news("sports") # type: ignore[arg-type] def test_ok_response_contains_headline(self): from tradingagents.dataflows.finnhub_news import get_market_news with patch(self._PATCH_TARGET, return_value=_json_response(MARKET_NEWS_OK)): result = get_market_news("general") assert "Fed Signals Rate Pause Ahead" in result # --------------------------------------------------------------------------- # 12. finnhub_news — get_insider_transactions # --------------------------------------------------------------------------- class TestGetInsiderTransactions: """get_insider_transactions returns a markdown table or 'no transactions' fallback.""" _PATCH_TARGET = "tradingagents.dataflows.finnhub_common.requests.get" def test_ok_response_has_markdown_table_header(self): from tradingagents.dataflows.finnhub_news import get_insider_transactions with patch(self._PATCH_TARGET, return_value=_json_response(INSIDER_TXN_OK)): result = get_insider_transactions("AAPL") # Markdown table header row assert "| Name |" in result or "|Name|" in result.replace(" ", "") def test_ok_response_contains_executive_name(self): from tradingagents.dataflows.finnhub_news import get_insider_transactions with patch(self._PATCH_TARGET, return_value=_json_response(INSIDER_TXN_OK)): result = get_insider_transactions("AAPL") assert "Tim Cook" in result def test_ok_response_transaction_code_mapped_to_label(self): """Transaction code 'S' should be rendered as 'Sell', not 'S'.""" from tradingagents.dataflows.finnhub_news import get_insider_transactions with patch(self._PATCH_TARGET, return_value=_json_response(INSIDER_TXN_OK)): result = get_insider_transactions("AAPL") assert "Sell" in result def test_empty_transactions_returns_no_transactions_message(self): from tradingagents.dataflows.finnhub_news import get_insider_transactions with patch(self._PATCH_TARGET, return_value=_json_response(INSIDER_TXN_EMPTY)): result = get_insider_transactions("AAPL") assert "No insider transactions" in result def test_result_contains_symbol(self): from tradingagents.dataflows.finnhub_news import get_insider_transactions with patch(self._PATCH_TARGET, return_value=_json_response(INSIDER_TXN_OK)): result = get_insider_transactions("AAPL") assert "AAPL" in result # --------------------------------------------------------------------------- # 13. finnhub_scanner — get_market_movers_finnhub # --------------------------------------------------------------------------- def _make_quote_side_effect(symbols_quotes: dict) -> callable: """Build a side_effect for _rate_limited_request that returns quote data per symbol.""" def side_effect(endpoint: str, params: dict) -> dict: symbol = params.get("symbol", "") if symbol in symbols_quotes: return symbols_quotes[symbol] # Default: valid but flat quote so it is not skipped return {"c": 100.0, "d": 0.0, "dp": 0.0, "h": 101.0, "l": 99.0, "o": 100.0, "pc": 100.0, "t": 1704153600} return side_effect class TestGetMarketMoversFinnhub: """get_market_movers_finnhub returns a sorted markdown table.""" _RATE_PATCH = "tradingagents.dataflows.finnhub_scanner._rate_limited_request" def _build_movers_side_effect(self) -> callable: """Return a mock that assigns unique change% values to the first few symbols.""" quotes_by_symbol = { "AAPL": {"c": 200.0, "d": 5.0, "dp": 2.5, "h": 202.0, "l": 198.0, "o": 195.0, "pc": 195.0, "t": 1704153600}, "MSFT": {"c": 400.0, "d": 3.0, "dp": 0.75, "h": 402.0, "l": 398.0, "o": 397.0, "pc": 397.0, "t": 1704153600}, "NVDA": {"c": 600.0, "d": 30.0, "dp": 5.26, "h": 605.0, "l": 595.0, "o": 570.0, "pc": 570.0, "t": 1704153600}, } return _make_quote_side_effect(quotes_by_symbol) def test_gainers_returns_markdown_table(self): from tradingagents.dataflows.finnhub_scanner import get_market_movers_finnhub with patch(self._RATE_PATCH, side_effect=self._build_movers_side_effect()): result = get_market_movers_finnhub("gainers") assert "| Symbol |" in result or "|Symbol|" in result.replace(" ", "") def test_gainers_sorted_highest_first(self): """The first data row after the header should be the top gainer.""" from tradingagents.dataflows.finnhub_scanner import get_market_movers_finnhub with patch(self._RATE_PATCH, side_effect=self._build_movers_side_effect()): result = get_market_movers_finnhub("gainers") # NVDA has the highest dp (+5.26%) so it must appear before AAPL (+2.5%) nvda_pos = result.find("NVDA") aapl_pos = result.find("AAPL") assert nvda_pos != -1 assert nvda_pos < aapl_pos def test_losers_sorted_lowest_first(self): from tradingagents.dataflows.finnhub_scanner import get_market_movers_finnhub losers_quotes = { "AAPL": {"c": 180.0, "d": -5.0, "dp": -2.7, "h": 186.0, "l": 179.0, "o": 185.0, "pc": 185.0, "t": 1704153600}, "MSFT": {"c": 390.0, "d": -1.0, "dp": -0.26, "h": 392.0, "l": 389.0, "o": 391.0, "pc": 391.0, "t": 1704153600}, } with patch(self._RATE_PATCH, side_effect=_make_quote_side_effect(losers_quotes)): result = get_market_movers_finnhub("losers") aapl_pos = result.find("AAPL") msft_pos = result.find("MSFT") assert aapl_pos != -1 assert aapl_pos < msft_pos def test_invalid_category_raises_value_error(self): from tradingagents.dataflows.finnhub_scanner import get_market_movers_finnhub with pytest.raises(ValueError, match="Invalid category"): get_market_movers_finnhub("unknown_cat") def test_all_quotes_fail_raises_finnhub_error(self): from tradingagents.dataflows.finnhub_common import FinnhubError from tradingagents.dataflows.finnhub_scanner import get_market_movers_finnhub with patch( self._RATE_PATCH, side_effect=FinnhubError("quota exceeded"), ): with pytest.raises(FinnhubError, match="All .* quote fetches failed"): get_market_movers_finnhub("gainers") # --------------------------------------------------------------------------- # 14. finnhub_scanner — get_market_indices_finnhub # --------------------------------------------------------------------------- class TestGetMarketIndicesFinnhub: """get_market_indices_finnhub builds a table of index levels.""" _RATE_PATCH = "tradingagents.dataflows.finnhub_scanner._rate_limited_request" def test_output_contains_major_market_indices_header(self): from tradingagents.dataflows.finnhub_scanner import get_market_indices_finnhub with patch(self._RATE_PATCH, return_value=QUOTE_OK): result = get_market_indices_finnhub() assert "Major Market Indices" in result def test_output_contains_spy_proxy_label(self): from tradingagents.dataflows.finnhub_scanner import get_market_indices_finnhub with patch(self._RATE_PATCH, return_value=QUOTE_OK): result = get_market_indices_finnhub() assert "SPY" in result or "S&P 500" in result def test_vix_row_has_no_dollar_sign(self): """VIX is unitless — it must not be prefixed with '$'.""" from tradingagents.dataflows.finnhub_scanner import get_market_indices_finnhub with patch(self._RATE_PATCH, return_value=QUOTE_OK): result = get_market_indices_finnhub() lines = result.split("\n") vix_lines = [l for l in lines if "VIX" in l] assert vix_lines, "Expected a VIX row" # The VIX price cell must not start with '$' for vix_line in vix_lines: cells = [c.strip() for c in vix_line.split("|") if c.strip()] # cells[1] is the Price cell for the VIX row if len(cells) >= 2: assert not cells[1].startswith("$"), f"VIX price should not have $: {cells[1]}" def test_all_fetches_fail_raises_finnhub_error(self): from tradingagents.dataflows.finnhub_common import FinnhubError from tradingagents.dataflows.finnhub_scanner import get_market_indices_finnhub with patch( self._RATE_PATCH, side_effect=FinnhubError("network failure"), ): with pytest.raises(FinnhubError, match="All market index fetches failed"): get_market_indices_finnhub() # --------------------------------------------------------------------------- # 15. finnhub_scanner — get_sector_performance_finnhub # --------------------------------------------------------------------------- class TestGetSectorPerformanceFinnhub: """get_sector_performance_finnhub returns sector ETF data.""" _RATE_PATCH = "tradingagents.dataflows.finnhub_scanner._rate_limited_request" def test_output_contains_sector_performance_header(self): from tradingagents.dataflows.finnhub_scanner import get_sector_performance_finnhub with patch(self._RATE_PATCH, return_value=QUOTE_OK): result = get_sector_performance_finnhub() assert "Sector Performance" in result def test_output_contains_at_least_one_sector_etf(self): from tradingagents.dataflows.finnhub_scanner import get_sector_performance_finnhub with patch(self._RATE_PATCH, return_value=QUOTE_OK): result = get_sector_performance_finnhub() # At least one known sector ETF ticker should appear etf_tickers = {"XLK", "XLV", "XLF", "XLE", "XLY", "XLP", "XLI", "XLB", "XLRE", "XLU", "XLC"} assert any(ticker in result for ticker in etf_tickers) def test_all_sectors_fail_raises_finnhub_error(self): from tradingagents.dataflows.finnhub_common import FinnhubError from tradingagents.dataflows.finnhub_scanner import get_sector_performance_finnhub with patch( self._RATE_PATCH, side_effect=FinnhubError("all failed"), ): with pytest.raises(FinnhubError): get_sector_performance_finnhub() # --------------------------------------------------------------------------- # 16. finnhub_scanner — get_topic_news_finnhub # --------------------------------------------------------------------------- class TestGetTopicNewsFinnhub: """get_topic_news_finnhub maps topic strings to Finnhub categories.""" _RATE_PATCH = "tradingagents.dataflows.finnhub_scanner._rate_limited_request" def test_crypto_topic_output_contains_topic(self): from tradingagents.dataflows.finnhub_scanner import get_topic_news_finnhub with patch(self._RATE_PATCH, return_value=MARKET_NEWS_OK): result = get_topic_news_finnhub("crypto") assert "crypto" in result.lower() def test_crypto_topic_maps_to_crypto_category(self): """Verify the request is made with category='crypto'.""" from tradingagents.dataflows.finnhub_scanner import get_topic_news_finnhub captured_params: list[dict] = [] def capture(endpoint, params): captured_params.append(dict(params)) return [] with patch(self._RATE_PATCH, side_effect=capture): get_topic_news_finnhub("crypto") assert any(p.get("category") == "crypto" for p in captured_params) def test_unknown_topic_defaults_to_general_category(self): """An unrecognised topic must fall back to 'general', not raise.""" from tradingagents.dataflows.finnhub_scanner import get_topic_news_finnhub captured_params: list[dict] = [] def capture(endpoint, params): captured_params.append(dict(params)) return [] with patch(self._RATE_PATCH, side_effect=capture): get_topic_news_finnhub("sports_scores") # unknown topic assert any(p.get("category") == "general" for p in captured_params) def test_mergers_topic_maps_to_merger_category(self): from tradingagents.dataflows.finnhub_scanner import get_topic_news_finnhub captured_params: list[dict] = [] def capture(endpoint, params): captured_params.append(dict(params)) return [] with patch(self._RATE_PATCH, side_effect=capture): get_topic_news_finnhub("mergers") assert any(p.get("category") == "merger" for p in captured_params) def test_limit_parameter_caps_articles_returned(self): """Only the first *limit* articles should appear.""" from tradingagents.dataflows.finnhub_scanner import get_topic_news_finnhub many_articles = [ {"headline": f"Headline {i}", "source": "src", "summary": "", "url": "", "datetime": 1704153600} for i in range(30) ] with patch(self._RATE_PATCH, return_value=many_articles): result = get_topic_news_finnhub("general", limit=5) # Only "Headline 0" through "Headline 4" should appear assert "Headline 4" in result assert "Headline 5" not in result # --------------------------------------------------------------------------- # 17. finnhub_indicators — get_indicator_finnhub # --------------------------------------------------------------------------- class TestGetIndicatorFinnhub: """get_indicator_finnhub returns formatted time-series strings.""" _PATCH_TARGET = "tradingagents.dataflows.finnhub_common.requests.get" def test_rsi_output_has_header_line(self): from tradingagents.dataflows.finnhub_indicators import get_indicator_finnhub with patch(self._PATCH_TARGET, return_value=_json_response(INDICATOR_RSI_OK)): result = get_indicator_finnhub("AAPL", "rsi", "2024-01-01", "2024-01-05") assert "RSI" in result def test_rsi_output_has_date_value_rows(self): from tradingagents.dataflows.finnhub_indicators import get_indicator_finnhub with patch(self._PATCH_TARGET, return_value=_json_response(INDICATOR_RSI_OK)): result = get_indicator_finnhub("AAPL", "rsi", "2024-01-01", "2024-01-05") # RSI values should appear: 62.5, 64.1 assert "62.5" in result or "62.5000" in result def test_macd_output_has_multi_column_header(self): from tradingagents.dataflows.finnhub_indicators import get_indicator_finnhub with patch(self._PATCH_TARGET, return_value=_json_response(INDICATOR_MACD_OK)): result = get_indicator_finnhub("AAPL", "macd", "2024-01-01", "2024-01-05") assert "MACD" in result assert "Signal" in result assert "Histogram" in result def test_bbands_output_has_upper_middle_lower_columns(self): from tradingagents.dataflows.finnhub_indicators import get_indicator_finnhub with patch(self._PATCH_TARGET, return_value=_json_response(INDICATOR_BBANDS_OK)): result = get_indicator_finnhub("AAPL", "bbands", "2024-01-01", "2024-01-05") assert "Upper" in result assert "Middle" in result assert "Lower" in result def test_no_data_status_raises_finnhub_error(self): from tradingagents.dataflows.finnhub_common import FinnhubError from tradingagents.dataflows.finnhub_indicators import get_indicator_finnhub with patch(self._PATCH_TARGET, return_value=_json_response(INDICATOR_NO_DATA)): with pytest.raises(FinnhubError, match="No indicator data"): get_indicator_finnhub("AAPL", "rsi", "2024-01-01", "2024-01-05") def test_invalid_indicator_name_raises_value_error(self): from tradingagents.dataflows.finnhub_indicators import get_indicator_finnhub with pytest.raises(ValueError, match="not supported"): get_indicator_finnhub("AAPL", "bad_indicator", "2024-01-01", "2024-01-05") # type: ignore[arg-type] def test_sma_indicator_accepted(self): sma_response = { "s": "ok", "t": [1704067200, 1704153600], "sma": [182.5, 183.1], } from tradingagents.dataflows.finnhub_indicators import get_indicator_finnhub with patch(self._PATCH_TARGET, return_value=_json_response(sma_response)): result = get_indicator_finnhub("AAPL", "sma", "2024-01-01", "2024-01-05") assert "SMA" in result def test_ema_indicator_accepted(self): ema_response = { "s": "ok", "t": [1704067200], "ema": [184.0], } from tradingagents.dataflows.finnhub_indicators import get_indicator_finnhub with patch(self._PATCH_TARGET, return_value=_json_response(ema_response)): result = get_indicator_finnhub("AAPL", "ema", "2024-01-01", "2024-01-05") assert "EMA" in result def test_atr_indicator_accepted(self): atr_response = { "s": "ok", "t": [1704067200], "atr": [3.25], } from tradingagents.dataflows.finnhub_indicators import get_indicator_finnhub with patch(self._PATCH_TARGET, return_value=_json_response(atr_response)): result = get_indicator_finnhub("AAPL", "atr", "2024-01-01", "2024-01-05") assert "ATR" in result def test_output_contains_symbol_and_date_range(self): from tradingagents.dataflows.finnhub_indicators import get_indicator_finnhub with patch(self._PATCH_TARGET, return_value=_json_response(INDICATOR_RSI_OK)): result = get_indicator_finnhub("AAPL", "rsi", "2024-01-01", "2024-01-05") assert "AAPL" in result assert "2024-01-01" in result assert "2024-01-05" in result def test_output_contains_indicator_description(self): """Each indicator should append a human-readable description at the bottom.""" from tradingagents.dataflows.finnhub_indicators import get_indicator_finnhub with patch(self._PATCH_TARGET, return_value=_json_response(INDICATOR_RSI_OK)): result = get_indicator_finnhub("AAPL", "rsi", "2024-01-01", "2024-01-05") # The description for RSI includes "overbought" assert "overbought" in result.lower() or "RSI" in result def test_unexpected_status_raises_finnhub_error(self): from tradingagents.dataflows.finnhub_common import FinnhubError from tradingagents.dataflows.finnhub_indicators import get_indicator_finnhub bad_status = {"s": "error", "t": [1704067200], "rsi": [55.0]} with patch(self._PATCH_TARGET, return_value=_json_response(bad_status)): with pytest.raises(FinnhubError): get_indicator_finnhub("AAPL", "rsi", "2024-01-01", "2024-01-05") # --------------------------------------------------------------------------- # 18. Edge cases & cross-cutting concerns # --------------------------------------------------------------------------- class TestEdgeCases: """Cross-cutting edge-case tests that span multiple modules.""" _PATCH_TARGET = "tradingagents.dataflows.finnhub_common.requests.get" def test_api_key_missing_when_calling_stock_candles(self, monkeypatch): """All public functions propagate APIKeyInvalidError when key is absent.""" from tradingagents.dataflows.finnhub_common import APIKeyInvalidError from tradingagents.dataflows.finnhub_stock import get_stock_candles monkeypatch.delenv("FINNHUB_API_KEY", raising=False) with pytest.raises(APIKeyInvalidError): # No mock needed — key check happens before HTTP call get_stock_candles("AAPL", "2024-01-01", "2024-01-03") def test_api_key_missing_when_calling_get_quote(self, monkeypatch): from tradingagents.dataflows.finnhub_common import APIKeyInvalidError from tradingagents.dataflows.finnhub_stock import get_quote monkeypatch.delenv("FINNHUB_API_KEY", raising=False) with pytest.raises(APIKeyInvalidError): get_quote("AAPL") def test_api_key_missing_when_calling_company_profile(self, monkeypatch): from tradingagents.dataflows.finnhub_common import APIKeyInvalidError from tradingagents.dataflows.finnhub_fundamentals import get_company_profile monkeypatch.delenv("FINNHUB_API_KEY", raising=False) with pytest.raises(APIKeyInvalidError): get_company_profile("AAPL") def test_rate_limit_error_propagates_from_stock_candles(self): from tradingagents.dataflows.finnhub_common import RateLimitError from tradingagents.dataflows.finnhub_stock import get_stock_candles with patch(self._PATCH_TARGET, return_value=_error_response(429, "Too Many Requests")): with pytest.raises(RateLimitError): get_stock_candles("AAPL", "2024-01-01", "2024-01-03") def test_rate_limit_error_propagates_from_company_profile(self): from tradingagents.dataflows.finnhub_common import RateLimitError from tradingagents.dataflows.finnhub_fundamentals import get_company_profile with patch(self._PATCH_TARGET, return_value=_error_response(429)): with pytest.raises(RateLimitError): get_company_profile("AAPL") def test_timeout_propagates_from_get_company_news(self): from tradingagents.dataflows.finnhub_common import ThirdPartyTimeoutError from tradingagents.dataflows.finnhub_news import get_company_news with patch( self._PATCH_TARGET, side_effect=requests.exceptions.Timeout("timeout"), ): with pytest.raises(ThirdPartyTimeoutError): get_company_news("AAPL", "2024-01-01", "2024-01-10") def test_timeout_propagates_from_get_basic_financials(self): from tradingagents.dataflows.finnhub_common import ThirdPartyTimeoutError from tradingagents.dataflows.finnhub_fundamentals import get_basic_financials with patch( self._PATCH_TARGET, side_effect=requests.exceptions.Timeout("timeout"), ): with pytest.raises(ThirdPartyTimeoutError): get_basic_financials("AAPL")