From 9174ebe7637c0be30a7bfe962c69d330c1b28b27 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:01:52 +0000 Subject: [PATCH] feat: add scanner tests, global demo key in conftest, remove 48 inline key patches Co-authored-by: aguzererler <6199053+aguzererler@users.noreply.github.com> --- tests/conftest.py | 32 +- tests/test_alpha_vantage_exceptions.py | 15 +- tests/test_alpha_vantage_integration.py | 90 ++- tests/test_e2e_api_integration.py | 46 +- tests/test_scanner_fallback.py | 34 +- tests/test_scanner_mocked.py | 729 ++++++++++++++++++++++++ tests/test_yfinance_integration.py | 162 ++++++ 7 files changed, 993 insertions(+), 115 deletions(-) create mode 100644 tests/test_scanner_mocked.py diff --git a/tests/conftest.py b/tests/conftest.py index b1bed2ce..5fa447c9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,18 +4,40 @@ import os import pytest +_DEMO_KEY = "demo" + + def pytest_configure(config): config.addinivalue_line("markers", "integration: tests that hit real external APIs") config.addinivalue_line("markers", "slow: tests that take a long time to run") +@pytest.fixture(autouse=True) +def _set_alpha_vantage_demo_key(monkeypatch): + """Ensure ALPHA_VANTAGE_API_KEY is always set to 'demo' unless the test + overrides it. This means no test needs its own patch.dict for the key.""" + if not os.environ.get("ALPHA_VANTAGE_API_KEY"): + monkeypatch.setenv("ALPHA_VANTAGE_API_KEY", _DEMO_KEY) + + @pytest.fixture def av_api_key(): - """Return the Alpha Vantage API key or skip the test.""" - key = os.environ.get("ALPHA_VANTAGE_API_KEY") - if not key: - pytest.skip("ALPHA_VANTAGE_API_KEY not set") - return key + """Return the Alpha Vantage API key ('demo' by default). + + Skips the test automatically when the Alpha Vantage API endpoint is not + reachable (e.g. sandboxed CI without outbound network access). + """ + import socket + + try: + socket.setdefaulttimeout(3) + socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect( + ("www.alphavantage.co", 443) + ) + except (socket.error, OSError): + pytest.skip("Alpha Vantage API not reachable — skipping live API test") + + return os.environ.get("ALPHA_VANTAGE_API_KEY", _DEMO_KEY) @pytest.fixture diff --git a/tests/test_alpha_vantage_exceptions.py b/tests/test_alpha_vantage_exceptions.py index 2bf90a4d..13ac611f 100644 --- a/tests/test_alpha_vantage_exceptions.py +++ b/tests/test_alpha_vantage_exceptions.py @@ -57,14 +57,13 @@ class TestMakeApiRequestErrors: def test_timeout_raises_timeout_error(self): """A timeout should raise ThirdPartyTimeoutError.""" - with patch.dict(os.environ, {"ALPHA_VANTAGE_API_KEY": "demo"}): - with pytest.raises(ThirdPartyTimeoutError): - # Use an impossibly short timeout - _make_api_request( - "TIME_SERIES_DAILY", - {"symbol": "IBM"}, - timeout=0.001, - ) + with pytest.raises(ThirdPartyTimeoutError): + # Use an impossibly short timeout + _make_api_request( + "TIME_SERIES_DAILY", + {"symbol": "IBM"}, + timeout=0.001, + ) def test_valid_request_succeeds(self, av_api_key): """A valid request with a real key should return data.""" diff --git a/tests/test_alpha_vantage_integration.py b/tests/test_alpha_vantage_integration.py index 28b621a6..8d77c845 100644 --- a/tests/test_alpha_vantage_integration.py +++ b/tests/test_alpha_vantage_integration.py @@ -97,9 +97,8 @@ class TestMakeApiRequest: with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_response(CSV_DAILY_ADJUSTED)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - result = _make_api_request("TIME_SERIES_DAILY_ADJUSTED", - {"symbol": "AAPL", "datatype": "csv"}) + result = _make_api_request("TIME_SERIES_DAILY_ADJUSTED", + {"symbol": "AAPL", "datatype": "csv"}) assert "timestamp" in result assert "186.00" in result @@ -112,9 +111,8 @@ class TestMakeApiRequest: with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_response(RATE_LIMIT_JSON)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - with pytest.raises(AlphaVantageRateLimitError): - _make_api_request("TIME_SERIES_DAILY_ADJUSTED", {"symbol": "AAPL"}) + with pytest.raises(AlphaVantageRateLimitError): + _make_api_request("TIME_SERIES_DAILY_ADJUSTED", {"symbol": "AAPL"}) def test_raises_api_key_error_for_invalid_api_key(self): """An 'Invalid API key' Information response raises an API-key-related error. @@ -145,9 +143,8 @@ class TestMakeApiRequest: with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", side_effect=TimeoutError("connection timed out")): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - with pytest.raises(TimeoutError): - _make_api_request("OVERVIEW", {"symbol": "AAPL"}) + with pytest.raises(TimeoutError): + _make_api_request("OVERVIEW", {"symbol": "AAPL"}) def test_http_error_propagates_on_non_200_status(self): """HTTP 4xx/5xx responses raise an error. @@ -164,9 +161,8 @@ class TestMakeApiRequest: with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=bad_resp): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - with pytest.raises(Exception): - _make_api_request("OVERVIEW", {"symbol": "AAPL"}) + with pytest.raises(Exception): + _make_api_request("OVERVIEW", {"symbol": "AAPL"}) # --------------------------------------------------------------------------- @@ -259,8 +255,7 @@ class TestAlphaVantageGetStock: with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_response(CSV_DAILY_ADJUSTED)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - result = get_stock("AAPL", "2024-01-01", "2024-01-05") + result = get_stock("AAPL", "2024-01-01", "2024-01-05") assert isinstance(result, str) @@ -276,8 +271,7 @@ class TestAlphaVantageGetStock: with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", side_effect=capture_request): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - get_stock("AAPL", "2020-01-01", "2020-01-05") + get_stock("AAPL", "2020-01-01", "2020-01-05") assert captured_params.get("outputsize") == "full" @@ -287,9 +281,8 @@ class TestAlphaVantageGetStock: with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_response(RATE_LIMIT_JSON)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - with pytest.raises(AlphaVantageRateLimitError): - get_stock("AAPL", "2024-01-01", "2024-01-05") + with pytest.raises(AlphaVantageRateLimitError): + get_stock("AAPL", "2024-01-01", "2024-01-05") # --------------------------------------------------------------------------- @@ -305,8 +298,7 @@ class TestAlphaVantageGetFundamentals: with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_response(OVERVIEW_JSON)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - result = get_fundamentals("AAPL") + result = get_fundamentals("AAPL") assert "Apple Inc" in result assert "TECHNOLOGY" in result @@ -317,9 +309,8 @@ class TestAlphaVantageGetFundamentals: with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_response(RATE_LIMIT_JSON)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - with pytest.raises(AlphaVantageRateLimitError): - get_fundamentals("AAPL") + with pytest.raises(AlphaVantageRateLimitError): + get_fundamentals("AAPL") class TestAlphaVantageGetBalanceSheet: @@ -329,8 +320,7 @@ class TestAlphaVantageGetBalanceSheet: payload = json.dumps({"symbol": "AAPL", "annualReports": [], "quarterlyReports": []}) with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_response(payload)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - result = get_balance_sheet("AAPL") + result = get_balance_sheet("AAPL") assert "AAPL" in result @@ -342,8 +332,7 @@ class TestAlphaVantageGetCashflow: payload = json.dumps({"symbol": "AAPL", "annualReports": [], "quarterlyReports": []}) with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_response(payload)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - result = get_cashflow("AAPL") + result = get_cashflow("AAPL") assert "AAPL" in result @@ -355,8 +344,7 @@ class TestAlphaVantageGetIncomeStatement: payload = json.dumps({"symbol": "AAPL", "annualReports": [], "quarterlyReports": []}) with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_response(payload)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - result = get_income_statement("AAPL") + result = get_income_statement("AAPL") assert "AAPL" in result @@ -397,8 +385,7 @@ class TestAlphaVantageGetNews: with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_response(NEWS_JSON)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - result = get_news("AAPL", "2024-01-01", "2024-01-05") + result = get_news("AAPL", "2024-01-01", "2024-01-05") assert "Apple Hits Record High" in result @@ -408,9 +395,8 @@ class TestAlphaVantageGetNews: with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_response(RATE_LIMIT_JSON)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - with pytest.raises(AlphaVantageRateLimitError): - get_news("AAPL", "2024-01-01", "2024-01-05") + with pytest.raises(AlphaVantageRateLimitError): + get_news("AAPL", "2024-01-01", "2024-01-05") class TestAlphaVantageGetGlobalNews: @@ -419,8 +405,7 @@ class TestAlphaVantageGetGlobalNews: with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_response(NEWS_JSON)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - result = get_global_news("2024-01-15", look_back_days=7) + result = get_global_news("2024-01-15", look_back_days=7) assert isinstance(result, str) @@ -436,8 +421,7 @@ class TestAlphaVantageGetGlobalNews: with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", side_effect=capture): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - get_global_news("2024-01-15", look_back_days=7) + get_global_news("2024-01-15", look_back_days=7) # time_from should be 7 days before 2024-01-15 → 2024-01-08 assert "20240108T0000" in captured_params.get("time_from", "") @@ -449,8 +433,7 @@ class TestAlphaVantageGetInsiderTransactions: with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_response(INSIDER_JSON)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - result = get_insider_transactions("AAPL") + result = get_insider_transactions("AAPL") assert "Tim Cook" in result @@ -460,9 +443,8 @@ class TestAlphaVantageGetInsiderTransactions: with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_response(RATE_LIMIT_JSON)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - with pytest.raises(AlphaVantageRateLimitError): - get_insider_transactions("AAPL") + with pytest.raises(AlphaVantageRateLimitError): + get_insider_transactions("AAPL") # --------------------------------------------------------------------------- @@ -477,10 +459,9 @@ class TestAlphaVantageGetIndicator: with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_response(CSV_RSI)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - result = get_indicator( - "AAPL", "rsi", "2024-01-05", look_back_days=5 - ) + result = get_indicator( + "AAPL", "rsi", "2024-01-05", look_back_days=5 + ) assert isinstance(result, str) assert "RSI" in result.upper() @@ -490,10 +471,9 @@ class TestAlphaVantageGetIndicator: with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_response(CSV_SMA)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - result = get_indicator( - "AAPL", "close_50_sma", "2024-01-05", look_back_days=5 - ) + result = get_indicator( + "AAPL", "close_50_sma", "2024-01-05", look_back_days=5 + ) assert isinstance(result, str) assert "SMA" in result.upper() @@ -510,8 +490,7 @@ class TestAlphaVantageGetIndicator: with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_response(RATE_LIMIT_JSON)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - result = get_indicator("AAPL", "rsi", "2024-01-05", look_back_days=5) + result = get_indicator("AAPL", "rsi", "2024-01-05", look_back_days=5) assert "Error" in result or "rate limit" in result.lower() @@ -519,8 +498,7 @@ class TestAlphaVantageGetIndicator: """VWMA is not directly available; a descriptive message is returned.""" from tradingagents.dataflows.alpha_vantage_indicator import get_indicator - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - result = get_indicator("AAPL", "vwma", "2024-01-05", look_back_days=5) + result = get_indicator("AAPL", "vwma", "2024-01-05", look_back_days=5) assert "VWMA" in result assert "not directly available" in result.lower() or "Volume Weighted" in result diff --git a/tests/test_e2e_api_integration.py b/tests/test_e2e_api_integration.py index 4fc1d8c2..9c300d0b 100644 --- a/tests/test_e2e_api_integration.py +++ b/tests/test_e2e_api_integration.py @@ -100,8 +100,7 @@ class TestRouteToVendor: with patch("tradingagents.dataflows.interface.get_config", return_value=patched_config): with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_av_response(_OHLCV_CSV_AV)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - result = route_to_vendor("get_stock_data", "AAPL", "2024-01-04", "2024-01-05") + result = route_to_vendor("get_stock_data", "AAPL", "2024-01-04", "2024-01-05") assert isinstance(result, str) @@ -124,13 +123,12 @@ class TestRouteToVendor: # AV returns a rate-limit response with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_av_response(_RATE_LIMIT_JSON)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - # yfinance is the fallback - with patch("tradingagents.dataflows.y_finance.yf.Ticker", - return_value=mock_ticker): - result = route_to_vendor( - "get_stock_data", "AAPL", "2024-01-04", "2024-01-05" - ) + # yfinance is the fallback + with patch("tradingagents.dataflows.y_finance.yf.Ticker", + return_value=mock_ticker): + result = route_to_vendor( + "get_stock_data", "AAPL", "2024-01-04", "2024-01-05" + ) assert isinstance(result, str) assert "AAPL" in result @@ -150,13 +148,12 @@ class TestRouteToVendor: with patch("tradingagents.dataflows.interface.get_config", return_value=patched_config): with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_av_response(_RATE_LIMIT_JSON)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - with patch( - "tradingagents.dataflows.y_finance.yf.Ticker", - side_effect=ConnectionError("network unavailable"), - ): - with pytest.raises(RuntimeError, match="No available vendor"): - route_to_vendor("get_stock_data", "AAPL", "2024-01-04", "2024-01-05") + with patch( + "tradingagents.dataflows.y_finance.yf.Ticker", + side_effect=ConnectionError("network unavailable"), + ): + with pytest.raises(RuntimeError, match="No available vendor"): + route_to_vendor("get_stock_data", "AAPL", "2024-01-04", "2024-01-05") def test_unknown_method_raises_value_error(self): from tradingagents.dataflows.interface import route_to_vendor @@ -195,8 +192,7 @@ class TestFullPipeline: with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_av_response(_OHLCV_CSV_AV)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - result = get_stock("AAPL", "2024-01-04", "2024-01-05") + result = get_stock("AAPL", "2024-01-04", "2024-01-05") assert isinstance(result, str) # pandas may reformat "185.00" → "185.0"; check for the numeric value @@ -230,8 +226,7 @@ class TestFullPipeline: with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_av_response(_OVERVIEW_JSON)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - result = get_fundamentals("AAPL") + result = get_fundamentals("AAPL") assert "Apple Inc" in result assert "TECHNOLOGY" in result @@ -262,8 +257,7 @@ class TestFullPipeline: with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_av_response(_NEWS_JSON)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - result = get_news("AAPL", "2024-01-01", "2024-01-05") + result = get_news("AAPL", "2024-01-01", "2024-01-05") assert "Apple Hits Record High" in result @@ -288,8 +282,7 @@ class TestFullPipeline: # --- Step 2: Alpha Vantage fundamentals --- with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_av_response(_OVERVIEW_JSON)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - fundamentals = get_fundamentals("AAPL") + fundamentals = get_fundamentals("AAPL") # --- Assertions --- assert isinstance(price_data, str) @@ -330,9 +323,8 @@ class TestFullPipeline: # Alpha Vantage rate-limits with patch("tradingagents.dataflows.alpha_vantage_common.requests.get", return_value=_mock_av_response(_RATE_LIMIT_JSON)): - with patch.dict("os.environ", {"ALPHA_VANTAGE_API_KEY": "demo"}): - with pytest.raises(AlphaVantageRateLimitError): - get_fundamentals("AAPL") + with pytest.raises(AlphaVantageRateLimitError): + get_fundamentals("AAPL") # --------------------------------------------------------------------------- diff --git a/tests/test_scanner_fallback.py b/tests/test_scanner_fallback.py index 134be897..0a0f6919 100644 --- a/tests/test_scanner_fallback.py +++ b/tests/test_scanner_fallback.py @@ -83,33 +83,29 @@ class TestAlphaVantageFailoverRaise: def test_sector_perf_raises_on_total_failure(self): """When every GLOBAL_QUOTE call fails, the function should raise.""" - with patch.dict(os.environ, {"ALPHA_VANTAGE_API_KEY": "demo"}): - with pytest.raises(AlphaVantageError, match="All .* sector queries failed"): - get_sector_performance_alpha_vantage() + with pytest.raises(AlphaVantageError, match="All .* sector queries failed"): + get_sector_performance_alpha_vantage() def test_industry_perf_raises_on_total_failure(self): """When every ticker quote fails, the function should raise.""" - with patch.dict(os.environ, {"ALPHA_VANTAGE_API_KEY": "demo"}): - with pytest.raises(AlphaVantageError, match="All .* ticker queries failed"): - get_industry_performance_alpha_vantage("technology") + with pytest.raises(AlphaVantageError, match="All .* ticker queries failed"): + get_industry_performance_alpha_vantage("technology") class TestRouteToVendorFallback: """Verify route_to_vendor falls back from AV to yfinance.""" def test_sector_perf_falls_back_to_yfinance(self): - with patch.dict(os.environ, {"ALPHA_VANTAGE_API_KEY": "demo"}): - from tradingagents.dataflows.interface import route_to_vendor - result = route_to_vendor("get_sector_performance") - # Should get yfinance data (no "Alpha Vantage" in header) - assert "Sector Performance Overview" in result - # Should have actual percentage data, not all errors - assert "Error:" not in result or result.count("Error:") < 3 + from tradingagents.dataflows.interface import route_to_vendor + result = route_to_vendor("get_sector_performance") + # Should get yfinance data (no "Alpha Vantage" in header) + assert "Sector Performance Overview" in result + # Should have actual percentage data, not all errors + assert "Error:" not in result or result.count("Error:") < 3 def test_industry_perf_falls_back_to_yfinance(self): - with patch.dict(os.environ, {"ALPHA_VANTAGE_API_KEY": "demo"}): - from tradingagents.dataflows.interface import route_to_vendor - result = route_to_vendor("get_industry_performance", "technology") - assert "Industry Performance" in result - # Should contain real ticker symbols - assert "N/A" not in result or result.count("N/A") < 5 + from tradingagents.dataflows.interface import route_to_vendor + result = route_to_vendor("get_industry_performance", "technology") + assert "Industry Performance" in result + # Should contain real ticker symbols + assert "N/A" not in result or result.count("N/A") < 5 diff --git a/tests/test_scanner_mocked.py b/tests/test_scanner_mocked.py new file mode 100644 index 00000000..39a21751 --- /dev/null +++ b/tests/test_scanner_mocked.py @@ -0,0 +1,729 @@ +"""Offline mocked tests for the market-wide scanner layer. + +Covers both yfinance and Alpha Vantage scanner functions, plus the +route_to_vendor scanner routing. All external calls are mocked so +these tests run without a network connection or API key. +""" + +import json +import pandas as pd +import pytest +from datetime import date, datetime +from unittest.mock import patch, MagicMock + + +# --------------------------------------------------------------------------- +# Helpers — mock data factories +# --------------------------------------------------------------------------- + +def _av_response(payload: dict | str) -> MagicMock: + """Build a mock requests.Response wrapping a JSON dict or raw string.""" + resp = MagicMock() + resp.status_code = 200 + resp.text = json.dumps(payload) if isinstance(payload, dict) else payload + resp.raise_for_status = MagicMock() + return resp + + +def _global_quote(symbol: str, price: float = 480.0, change: float = 2.5, + change_pct: str = "0.52%") -> dict: + return { + "Global Quote": { + "01. symbol": symbol, + "05. price": str(price), + "09. change": str(change), + "10. change percent": change_pct, + } + } + + +def _time_series_daily(symbol: str) -> dict: + """Return a minimal TIME_SERIES_DAILY JSON payload.""" + return { + "Meta Data": {"2. Symbol": symbol}, + "Time Series (Daily)": { + "2024-01-08": {"4. close": "482.00"}, + "2024-01-05": {"4. close": "480.00"}, + "2024-01-04": {"4. close": "475.00"}, + }, + } + + +_TOP_GAINERS_LOSERS = { + "top_gainers": [ + {"ticker": "NVDA", "price": "620.00", "change_percentage": "5.10%", "volume": "45000000"}, + {"ticker": "AMD", "price": "175.00", "change_percentage": "3.20%", "volume": "32000000"}, + ], + "top_losers": [ + {"ticker": "INTC", "price": "31.00", "change_percentage": "-4.50%", "volume": "28000000"}, + ], + "most_actively_traded": [ + {"ticker": "TSLA", "price": "240.00", "change_percentage": "1.80%", "volume": "90000000"}, + ], +} + +_NEWS_SENTIMENT = { + "feed": [ + { + "title": "AI Stocks Rally on Positive Earnings", + "summary": "Tech stocks continued their upward climb.", + "source": "Reuters", + "url": "https://example.com/news/1", + "time_published": "20240108T130000", + "overall_sentiment_score": 0.35, + } + ] +} + + +# --------------------------------------------------------------------------- +# yfinance scanner — get_market_movers_yfinance +# --------------------------------------------------------------------------- + +class TestYfinanceScannerMarketMovers: + """Offline tests for get_market_movers_yfinance.""" + + def _screener_data(self, category: str = "day_gainers") -> dict: + return { + "quotes": [ + { + "symbol": "NVDA", + "shortName": "NVIDIA Corp", + "regularMarketPrice": 620.00, + "regularMarketChangePercent": 5.10, + "regularMarketVolume": 45_000_000, + "marketCap": 1_500_000_000_000, + }, + { + "symbol": "AMD", + "shortName": "Advanced Micro Devices", + "regularMarketPrice": 175.00, + "regularMarketChangePercent": 3.20, + "regularMarketVolume": 32_000_000, + "marketCap": 280_000_000_000, + }, + ] + } + + def test_returns_markdown_table_for_day_gainers(self): + from tradingagents.dataflows.yfinance_scanner import get_market_movers_yfinance + + with patch("tradingagents.dataflows.yfinance_scanner.yf.screener.screen", + return_value=self._screener_data()): + result = get_market_movers_yfinance("day_gainers") + + assert isinstance(result, str) + assert "Market Movers" in result + assert "NVDA" in result + assert "5.10%" in result + assert "|" in result # markdown table + + def test_returns_markdown_table_for_day_losers(self): + from tradingagents.dataflows.yfinance_scanner import get_market_movers_yfinance + + data = {"quotes": [{"symbol": "INTC", "shortName": "Intel", "regularMarketPrice": 31.00, + "regularMarketChangePercent": -4.5, "regularMarketVolume": 28_000_000, + "marketCap": 130_000_000_000}]} + with patch("tradingagents.dataflows.yfinance_scanner.yf.screener.screen", + return_value=data): + result = get_market_movers_yfinance("day_losers") + + assert "Market Movers" in result + assert "INTC" in result + + def test_invalid_category_returns_error_string(self): + from tradingagents.dataflows.yfinance_scanner import get_market_movers_yfinance + + result = get_market_movers_yfinance("not_a_category") + assert "Invalid category" in result + + def test_empty_quotes_returns_no_data_message(self): + from tradingagents.dataflows.yfinance_scanner import get_market_movers_yfinance + + with patch("tradingagents.dataflows.yfinance_scanner.yf.screener.screen", + return_value={"quotes": []}): + result = get_market_movers_yfinance("day_gainers") + + assert "No quotes found" in result + + def test_api_error_returns_error_string(self): + from tradingagents.dataflows.yfinance_scanner import get_market_movers_yfinance + + with patch("tradingagents.dataflows.yfinance_scanner.yf.screener.screen", + side_effect=Exception("network failure")): + result = get_market_movers_yfinance("day_gainers") + + assert "Error" in result + + +# --------------------------------------------------------------------------- +# yfinance scanner — get_market_indices_yfinance +# --------------------------------------------------------------------------- + +class TestYfinanceScannerMarketIndices: + """Offline tests for get_market_indices_yfinance.""" + + def _make_multi_etf_df(self) -> pd.DataFrame: + """Build a minimal multi-ticker Close DataFrame as yf.download returns.""" + symbols = ["^GSPC", "^DJI", "^IXIC", "^VIX", "^RUT"] + idx = pd.date_range("2024-01-04", periods=3, freq="B", tz="UTC") + closes = pd.DataFrame( + {s: [4800.0 + i * 10, 4810.0 + i * 10, 4820.0 + i * 10] for i, s in enumerate(symbols)}, + index=idx, + ) + return pd.DataFrame({"Close": closes}) + + def test_returns_markdown_table_with_indices(self): + from tradingagents.dataflows.yfinance_scanner import get_market_indices_yfinance + + # Multi-symbol download returns a MultiIndex DataFrame + symbols = ["^GSPC", "^DJI", "^IXIC", "^VIX", "^RUT"] + idx = pd.date_range("2024-01-04", periods=5, freq="B") + close_data = {s: [4800.0 + i for i in range(5)] for s in symbols} + # yf.download with multiple symbols returns DataFrame with MultiIndex columns + multi_df = pd.DataFrame(close_data, index=idx) + multi_df.columns = pd.MultiIndex.from_product([["Close"], symbols]) + + with patch("tradingagents.dataflows.yfinance_scanner.yf.download", + return_value=multi_df): + result = get_market_indices_yfinance() + + assert isinstance(result, str) + assert "Market Indices" in result or "Index" in result.split("\n")[0] + + def test_returns_string_on_download_error(self): + from tradingagents.dataflows.yfinance_scanner import get_market_indices_yfinance + + with patch("tradingagents.dataflows.yfinance_scanner.yf.download", + side_effect=Exception("network error")): + result = get_market_indices_yfinance() + + assert isinstance(result, str) + + +# --------------------------------------------------------------------------- +# yfinance scanner — get_sector_performance_yfinance +# --------------------------------------------------------------------------- + +class TestYfinanceScannerSectorPerformance: + """Offline tests for get_sector_performance_yfinance.""" + + def _make_sector_df(self) -> pd.DataFrame: + """Multi-symbol ETF DataFrame covering 6 months of daily closes.""" + etfs = ["XLK", "XLV", "XLF", "XLE", "XLY", "XLP", "XLI", "XLB", "XLRE", "XLU", "XLC"] + # 130 trading days ~ 6 months + idx = pd.date_range("2023-07-01", periods=130, freq="B") + data = {e: [100.0 + i * 0.01 for i in range(130)] for e in etfs} + df = pd.DataFrame(data, index=idx) + df.columns = pd.MultiIndex.from_product([["Close"], etfs]) + return df + + def test_returns_sector_performance_table(self): + from tradingagents.dataflows.yfinance_scanner import get_sector_performance_yfinance + + with patch("tradingagents.dataflows.yfinance_scanner.yf.download", + return_value=self._make_sector_df()): + result = get_sector_performance_yfinance() + + assert isinstance(result, str) + assert "Sector Performance Overview" in result + assert "|" in result + + def test_contains_all_sectors(self): + from tradingagents.dataflows.yfinance_scanner import get_sector_performance_yfinance + + with patch("tradingagents.dataflows.yfinance_scanner.yf.download", + return_value=self._make_sector_df()): + result = get_sector_performance_yfinance() + + # 11 GICS sectors should all appear + for sector in ["Technology", "Healthcare", "Financials", "Energy"]: + assert sector in result + + def test_download_error_returns_error_string(self): + from tradingagents.dataflows.yfinance_scanner import get_sector_performance_yfinance + + with patch("tradingagents.dataflows.yfinance_scanner.yf.download", + side_effect=Exception("connection refused")): + result = get_sector_performance_yfinance() + + assert "Error" in result + + +# --------------------------------------------------------------------------- +# yfinance scanner — get_industry_performance_yfinance +# --------------------------------------------------------------------------- + +class TestYfinanceScannerIndustryPerformance: + """Offline tests for get_industry_performance_yfinance.""" + + def _mock_sector_with_companies(self) -> MagicMock: + top_companies = pd.DataFrame( + { + "name": ["Apple Inc.", "Microsoft Corp", "NVIDIA Corp"], + "rating": [4.5, 4.8, 4.2], + "market weight": [0.072, 0.065, 0.051], + }, + index=pd.Index(["AAPL", "MSFT", "NVDA"], name="symbol"), + ) + mock_sector = MagicMock() + mock_sector.top_companies = top_companies + return mock_sector + + def test_returns_industry_table_for_valid_sector(self): + from tradingagents.dataflows.yfinance_scanner import get_industry_performance_yfinance + + with patch("tradingagents.dataflows.yfinance_scanner.yf.Sector", + return_value=self._mock_sector_with_companies()): + result = get_industry_performance_yfinance("technology") + + assert isinstance(result, str) + assert "Industry Performance" in result + assert "AAPL" in result + assert "Apple Inc." in result + + def test_empty_top_companies_returns_no_data_message(self): + from tradingagents.dataflows.yfinance_scanner import get_industry_performance_yfinance + + mock_sector = MagicMock() + mock_sector.top_companies = pd.DataFrame() + + with patch("tradingagents.dataflows.yfinance_scanner.yf.Sector", + return_value=mock_sector): + result = get_industry_performance_yfinance("technology") + + assert "No industry data found" in result + + def test_none_top_companies_returns_no_data_message(self): + from tradingagents.dataflows.yfinance_scanner import get_industry_performance_yfinance + + mock_sector = MagicMock() + mock_sector.top_companies = None + + with patch("tradingagents.dataflows.yfinance_scanner.yf.Sector", + return_value=mock_sector): + result = get_industry_performance_yfinance("healthcare") + + assert "No industry data found" in result + + def test_sector_error_returns_error_string(self): + from tradingagents.dataflows.yfinance_scanner import get_industry_performance_yfinance + + with patch("tradingagents.dataflows.yfinance_scanner.yf.Sector", + side_effect=Exception("yfinance unavailable")): + result = get_industry_performance_yfinance("technology") + + assert "Error" in result + + +# --------------------------------------------------------------------------- +# yfinance scanner — get_topic_news_yfinance +# --------------------------------------------------------------------------- + +class TestYfinanceScannerTopicNews: + """Offline tests for get_topic_news_yfinance.""" + + def _mock_search(self, title: str = "AI Revolution in Tech") -> MagicMock: + mock_search = MagicMock() + mock_search.news = [ + { + "title": title, + "publisher": "TechCrunch", + "link": "https://techcrunch.com/story", + "summary": "Artificial intelligence is transforming the industry.", + } + ] + return mock_search + + def test_returns_formatted_news_for_topic(self): + from tradingagents.dataflows.yfinance_scanner import get_topic_news_yfinance + + with patch("tradingagents.dataflows.yfinance_scanner.yf.Search", + return_value=self._mock_search()): + result = get_topic_news_yfinance("artificial intelligence") + + assert isinstance(result, str) + assert "AI Revolution in Tech" in result + assert "News for Topic" in result + + def test_no_results_returns_no_news_message(self): + from tradingagents.dataflows.yfinance_scanner import get_topic_news_yfinance + + mock_search = MagicMock() + mock_search.news = [] + + with patch("tradingagents.dataflows.yfinance_scanner.yf.Search", + return_value=mock_search): + result = get_topic_news_yfinance("obscure_topic") + + assert "No news found" in result + + def test_handles_nested_content_structure(self): + from tradingagents.dataflows.yfinance_scanner import get_topic_news_yfinance + + mock_search = MagicMock() + mock_search.news = [ + { + "content": { + "title": "Semiconductor Demand Surges", + "summary": "Chip makers report record orders.", + "provider": {"displayName": "Bloomberg"}, + "canonicalUrl": {"url": "https://bloomberg.com/chips"}, + } + } + ] + + with patch("tradingagents.dataflows.yfinance_scanner.yf.Search", + return_value=mock_search): + result = get_topic_news_yfinance("semiconductors") + + assert "Semiconductor Demand Surges" in result + + +# --------------------------------------------------------------------------- +# Alpha Vantage scanner — get_market_movers_alpha_vantage +# --------------------------------------------------------------------------- + +class TestAVScannerMarketMovers: + """Offline mocked tests for get_market_movers_alpha_vantage.""" + + def test_day_gainers_returns_markdown_table(self): + from tradingagents.dataflows.alpha_vantage_scanner import get_market_movers_alpha_vantage + + with patch("tradingagents.dataflows.alpha_vantage_scanner._rate_limited_request", + return_value=json.dumps(_TOP_GAINERS_LOSERS)): + result = get_market_movers_alpha_vantage("day_gainers") + + assert "Market Movers" in result + assert "NVDA" in result + assert "5.10%" in result + assert "|" in result + + def test_day_losers_returns_markdown_table(self): + from tradingagents.dataflows.alpha_vantage_scanner import get_market_movers_alpha_vantage + + with patch("tradingagents.dataflows.alpha_vantage_scanner._rate_limited_request", + return_value=json.dumps(_TOP_GAINERS_LOSERS)): + result = get_market_movers_alpha_vantage("day_losers") + + assert "INTC" in result + + def test_most_actives_returns_markdown_table(self): + from tradingagents.dataflows.alpha_vantage_scanner import get_market_movers_alpha_vantage + + with patch("tradingagents.dataflows.alpha_vantage_scanner._rate_limited_request", + return_value=json.dumps(_TOP_GAINERS_LOSERS)): + result = get_market_movers_alpha_vantage("most_actives") + + assert "TSLA" in result + + def test_invalid_category_raises_value_error(self): + from tradingagents.dataflows.alpha_vantage_scanner import get_market_movers_alpha_vantage + + with pytest.raises(ValueError, match="Invalid category"): + get_market_movers_alpha_vantage("not_valid") + + def test_rate_limit_error_propagates(self): + from tradingagents.dataflows.alpha_vantage_scanner import get_market_movers_alpha_vantage + from tradingagents.dataflows.alpha_vantage_common import RateLimitError + + with patch("tradingagents.dataflows.alpha_vantage_scanner._rate_limited_request", + side_effect=RateLimitError("rate limited")): + with pytest.raises(RateLimitError): + get_market_movers_alpha_vantage("day_gainers") + + +# --------------------------------------------------------------------------- +# Alpha Vantage scanner — get_market_indices_alpha_vantage +# --------------------------------------------------------------------------- + +class TestAVScannerMarketIndices: + """Offline mocked tests for get_market_indices_alpha_vantage.""" + + def test_returns_markdown_table_with_index_names(self): + from tradingagents.dataflows.alpha_vantage_scanner import get_market_indices_alpha_vantage + + def fake_request(function_name, params, **kwargs): + symbol = params.get("symbol", "SPY") + return json.dumps(_global_quote(symbol)) + + with patch("tradingagents.dataflows.alpha_vantage_scanner._rate_limited_request", + side_effect=fake_request): + result = get_market_indices_alpha_vantage() + + assert "Market Indices" in result + assert "|" in result + assert any(name in result for name in ["S&P 500", "Dow Jones", "NASDAQ"]) + + def test_all_proxies_appear_in_output(self): + from tradingagents.dataflows.alpha_vantage_scanner import get_market_indices_alpha_vantage + + def fake_request(function_name, params, **kwargs): + return json.dumps(_global_quote(params.get("symbol", "SPY"))) + + with patch("tradingagents.dataflows.alpha_vantage_scanner._rate_limited_request", + side_effect=fake_request): + result = get_market_indices_alpha_vantage() + + # All 4 ETF proxies should appear + for proxy in ["SPY", "DIA", "QQQ", "IWM"]: + assert proxy in result + + +# --------------------------------------------------------------------------- +# Alpha Vantage scanner — get_sector_performance_alpha_vantage +# --------------------------------------------------------------------------- + +class TestAVScannerSectorPerformance: + """Offline mocked tests for get_sector_performance_alpha_vantage.""" + + def _make_fake_request(self): + """Return a side_effect function handling both GLOBAL_QUOTE and TIME_SERIES_DAILY.""" + def fake(function_name, params, **kwargs): + if function_name == "GLOBAL_QUOTE": + symbol = params.get("symbol", "XLK") + return json.dumps(_global_quote(symbol)) + elif function_name == "TIME_SERIES_DAILY": + symbol = params.get("symbol", "XLK") + return json.dumps(_time_series_daily(symbol)) + return json.dumps({}) + return fake + + def test_returns_sector_table_with_percentages(self): + from tradingagents.dataflows.alpha_vantage_scanner import get_sector_performance_alpha_vantage + + with patch("tradingagents.dataflows.alpha_vantage_scanner._rate_limited_request", + side_effect=self._make_fake_request()): + result = get_sector_performance_alpha_vantage() + + assert "Sector Performance Overview" in result + assert "|" in result + + def test_all_eleven_sectors_in_output(self): + from tradingagents.dataflows.alpha_vantage_scanner import get_sector_performance_alpha_vantage + + with patch("tradingagents.dataflows.alpha_vantage_scanner._rate_limited_request", + side_effect=self._make_fake_request()): + result = get_sector_performance_alpha_vantage() + + for sector in ["Technology", "Healthcare", "Financials", "Energy"]: + assert sector in result + + def test_all_errors_raises_alpha_vantage_error(self): + """If ALL sector ETF requests fail, AlphaVantageError is raised for fallback.""" + from tradingagents.dataflows.alpha_vantage_scanner import get_sector_performance_alpha_vantage + from tradingagents.dataflows.alpha_vantage_common import AlphaVantageError, RateLimitError + + with patch("tradingagents.dataflows.alpha_vantage_scanner._rate_limited_request", + side_effect=RateLimitError("rate limited")): + with pytest.raises(AlphaVantageError): + get_sector_performance_alpha_vantage() + + +# --------------------------------------------------------------------------- +# Alpha Vantage scanner — get_industry_performance_alpha_vantage +# --------------------------------------------------------------------------- + +class TestAVScannerIndustryPerformance: + """Offline mocked tests for get_industry_performance_alpha_vantage.""" + + def test_returns_table_for_technology_sector(self): + from tradingagents.dataflows.alpha_vantage_scanner import get_industry_performance_alpha_vantage + + def fake_request(function_name, params, **kwargs): + symbol = params.get("symbol", "AAPL") + return json.dumps(_global_quote(symbol, price=185.0, change_pct="+1.20%")) + + with patch("tradingagents.dataflows.alpha_vantage_scanner._rate_limited_request", + side_effect=fake_request): + result = get_industry_performance_alpha_vantage("technology") + + assert "Industry Performance" in result + assert "|" in result + assert any(t in result for t in ["AAPL", "MSFT", "NVDA"]) + + def test_invalid_sector_raises_value_error(self): + from tradingagents.dataflows.alpha_vantage_scanner import get_industry_performance_alpha_vantage + + with pytest.raises(ValueError, match="Unknown sector"): + get_industry_performance_alpha_vantage("not_a_real_sector") + + def test_sorted_by_change_percent_descending(self): + """Results should be sorted by change % descending.""" + from tradingagents.dataflows.alpha_vantage_scanner import get_industry_performance_alpha_vantage + + # Alternate high/low changes to verify sort order + prices = {"AAPL": ("180.00", "+5.00%"), "MSFT": ("380.00", "+1.00%"), + "NVDA": ("620.00", "+8.00%"), "GOOGL": ("140.00", "+2.50%"), + "META": ("350.00", "+3.10%"), "AVGO": ("850.00", "+0.50%"), + "ADBE": ("550.00", "+4.20%"), "CRM": ("275.00", "+1.80%"), + "AMD": ("170.00", "+6.30%"), "INTC": ("31.00", "-2.10%")} + + def fake_request(function_name, params, **kwargs): + symbol = params.get("symbol", "AAPL") + p, c = prices.get(symbol, ("100.00", "0.00%")) + return json.dumps({ + "Global Quote": {"01. symbol": symbol, "05. price": p, + "09. change": "1.00", "10. change percent": c} + }) + + with patch("tradingagents.dataflows.alpha_vantage_scanner._rate_limited_request", + side_effect=fake_request): + result = get_industry_performance_alpha_vantage("technology") + + # NVDA (+8%) should appear before INTC (-2.1%) + nvda_pos = result.find("NVDA") + intc_pos = result.find("INTC") + assert nvda_pos != -1 and intc_pos != -1 + assert nvda_pos < intc_pos + + +# --------------------------------------------------------------------------- +# Alpha Vantage scanner — get_topic_news_alpha_vantage +# --------------------------------------------------------------------------- + +class TestAVScannerTopicNews: + """Offline mocked tests for get_topic_news_alpha_vantage.""" + + def test_returns_news_articles_for_known_topic(self): + from tradingagents.dataflows.alpha_vantage_scanner import get_topic_news_alpha_vantage + + with patch("tradingagents.dataflows.alpha_vantage_scanner._rate_limited_request", + return_value=json.dumps(_NEWS_SENTIMENT)): + result = get_topic_news_alpha_vantage("market", limit=5) + + assert "News for Topic" in result + assert "AI Stocks Rally on Positive Earnings" in result + + def test_known_topic_is_mapped_to_av_value(self): + """Topic strings like 'market' are remapped to AV-specific topic keys.""" + from tradingagents.dataflows.alpha_vantage_scanner import get_topic_news_alpha_vantage + + captured = {} + + def capture_request(function_name, params, **kwargs): + captured.update(params) + return json.dumps(_NEWS_SENTIMENT) + + with patch("tradingagents.dataflows.alpha_vantage_scanner._rate_limited_request", + side_effect=capture_request): + get_topic_news_alpha_vantage("market", limit=5) + + # "market" maps to "financial_markets" in _TOPIC_MAP + assert captured.get("topics") == "financial_markets" + + def test_unknown_topic_passed_through(self): + """Topics not in the map are forwarded to the API as-is.""" + from tradingagents.dataflows.alpha_vantage_scanner import get_topic_news_alpha_vantage + + captured = {} + + def capture_request(function_name, params, **kwargs): + captured.update(params) + return json.dumps(_NEWS_SENTIMENT) + + with patch("tradingagents.dataflows.alpha_vantage_scanner._rate_limited_request", + side_effect=capture_request): + get_topic_news_alpha_vantage("custom_topic", limit=3) + + assert captured.get("topics") == "custom_topic" + + def test_empty_feed_returns_no_articles_message(self): + from tradingagents.dataflows.alpha_vantage_scanner import get_topic_news_alpha_vantage + + empty = {"feed": []} + with patch("tradingagents.dataflows.alpha_vantage_scanner._rate_limited_request", + return_value=json.dumps(empty)): + result = get_topic_news_alpha_vantage("earnings", limit=5) + + assert "No articles" in result + + def test_rate_limit_error_propagates(self): + from tradingagents.dataflows.alpha_vantage_scanner import get_topic_news_alpha_vantage + from tradingagents.dataflows.alpha_vantage_common import RateLimitError + + with patch("tradingagents.dataflows.alpha_vantage_scanner._rate_limited_request", + side_effect=RateLimitError("rate limited")): + with pytest.raises(RateLimitError): + get_topic_news_alpha_vantage("technology") + + +# --------------------------------------------------------------------------- +# Scanner routing — route_to_vendor for scanner methods +# --------------------------------------------------------------------------- + +class TestScannerRouting: + """End-to-end routing tests for scanner_data methods via route_to_vendor.""" + + def test_get_market_movers_routes_to_yfinance_by_default(self): + """Default config uses yfinance for scanner_data.""" + from tradingagents.dataflows.interface import route_to_vendor + + screener_data = { + "quotes": [{"symbol": "NVDA", "shortName": "NVIDIA", "regularMarketPrice": 620.0, + "regularMarketChangePercent": 5.1, "regularMarketVolume": 45_000_000, + "marketCap": 1_500_000_000_000}] + } + with patch("tradingagents.dataflows.yfinance_scanner.yf.screener.screen", + return_value=screener_data): + result = route_to_vendor("get_market_movers", "day_gainers") + + assert isinstance(result, str) + assert "NVDA" in result + + def test_get_sector_performance_routes_to_yfinance_by_default(self): + from tradingagents.dataflows.interface import route_to_vendor + + etfs = ["XLK", "XLV", "XLF", "XLE", "XLY", "XLP", "XLI", "XLB", "XLRE", "XLU", "XLC"] + idx = pd.date_range("2023-07-01", periods=130, freq="B") + close_data = {e: [100.0 + i * 0.01 for i in range(130)] for e in etfs} + df = pd.DataFrame(close_data, index=idx) + df.columns = pd.MultiIndex.from_product([["Close"], etfs]) + + with patch("tradingagents.dataflows.yfinance_scanner.yf.download", return_value=df): + result = route_to_vendor("get_sector_performance") + + assert isinstance(result, str) + assert "Sector Performance Overview" in result + + def test_get_market_movers_falls_back_to_yfinance_when_av_fails(self): + """When AV scanner raises AlphaVantageError, fallback to yfinance is used.""" + from tradingagents.dataflows.interface import route_to_vendor + from tradingagents.dataflows.config import get_config + from tradingagents.dataflows.alpha_vantage_common import AlphaVantageError + + original_config = get_config() + patched_config = { + **original_config, + "data_vendors": {**original_config.get("data_vendors", {}), "scanner_data": "alpha_vantage"}, + } + + screener_data = { + "quotes": [{"symbol": "AMD", "shortName": "AMD", "regularMarketPrice": 175.0, + "regularMarketChangePercent": 3.2, "regularMarketVolume": 32_000_000, + "marketCap": 280_000_000_000}] + } + + with patch("tradingagents.dataflows.interface.get_config", return_value=patched_config): + # AV market movers raises → fallback to yfinance + with patch("tradingagents.dataflows.alpha_vantage_scanner._rate_limited_request", + side_effect=AlphaVantageError("rate limited")): + with patch("tradingagents.dataflows.yfinance_scanner.yf.screener.screen", + return_value=screener_data): + result = route_to_vendor("get_market_movers", "day_gainers") + + assert isinstance(result, str) + assert "AMD" in result + + def test_get_topic_news_routes_correctly(self): + from tradingagents.dataflows.interface import route_to_vendor + + mock_search = MagicMock() + mock_search.news = [{"title": "Fed Signals Rate Cut", "publisher": "Reuters", + "link": "https://example.com", "summary": "Fed news."}] + + with patch("tradingagents.dataflows.yfinance_scanner.yf.Search", + return_value=mock_search): + result = route_to_vendor("get_topic_news", "economy") + + assert isinstance(result, str) diff --git a/tests/test_yfinance_integration.py b/tests/test_yfinance_integration.py index 78bc88f3..41696225 100644 --- a/tests/test_yfinance_integration.py +++ b/tests/test_yfinance_integration.py @@ -403,3 +403,165 @@ class TestGetInsiderTransactions: result = get_insider_transactions("AAPL") assert "Error" in result + + +# --------------------------------------------------------------------------- +# get_stock_stats_indicators_window +# --------------------------------------------------------------------------- + +class TestGetStockStatsIndicatorsWindow: + """Tests for get_stock_stats_indicators_window (technical indicators).""" + + def _bulk_rsi_data(self): + """Return a realistic dict of date→rsi_value as _get_stock_stats_bulk would.""" + return { + "2024-01-08": "62.34", + "2024-01-07": "N/A", # weekend + "2024-01-06": "N/A", # weekend + "2024-01-05": "59.12", + "2024-01-04": "55.67", + "2024-01-03": "50.00", + } + + def test_returns_formatted_indicator_string(self): + """Success path: returns a multi-line string with dates and RSI values.""" + from tradingagents.dataflows.y_finance import get_stock_stats_indicators_window + + with patch( + "tradingagents.dataflows.y_finance._get_stock_stats_bulk", + return_value=self._bulk_rsi_data(), + ): + result = get_stock_stats_indicators_window("AAPL", "rsi", "2024-01-08", 5) + + assert "rsi" in result + assert "2024-01-08" in result + assert "62.34" in result + + def test_includes_indicator_description(self): + """The returned string includes the indicator description / usage notes.""" + from tradingagents.dataflows.y_finance import get_stock_stats_indicators_window + + with patch( + "tradingagents.dataflows.y_finance._get_stock_stats_bulk", + return_value=self._bulk_rsi_data(), + ): + result = get_stock_stats_indicators_window("AAPL", "rsi", "2024-01-08", 5) + + # Every supported indicator has a description string + assert "RSI" in result or "momentum" in result.lower() + + def test_unsupported_indicator_raises_value_error(self): + """Requesting an unsupported indicator raises ValueError before any network call.""" + from tradingagents.dataflows.y_finance import get_stock_stats_indicators_window + + with pytest.raises(ValueError, match="not supported"): + get_stock_stats_indicators_window("AAPL", "unknown_indicator", "2024-01-08", 5) + + def test_bulk_exception_triggers_fallback(self): + """If _get_stock_stats_bulk raises, the function falls back gracefully.""" + from tradingagents.dataflows.y_finance import get_stock_stats_indicators_window + + with patch( + "tradingagents.dataflows.y_finance._get_stock_stats_bulk", + side_effect=Exception("stockstats unavailable"), + ): + with patch( + "tradingagents.dataflows.y_finance.get_stockstats_indicator", + return_value="45.00", + ): + result = get_stock_stats_indicators_window("AAPL", "rsi", "2024-01-08", 3) + + assert isinstance(result, str) + assert "rsi" in result + + +# --------------------------------------------------------------------------- +# get_global_news_yfinance +# --------------------------------------------------------------------------- + +class TestGetGlobalNewsYfinance: + """Tests for get_global_news_yfinance.""" + + def _mock_search_with_article(self): + """Return a mock yf.Search object with one flat-structured news article.""" + mock_search = MagicMock() + mock_search.news = [ + { + "title": "Fed Holds Rates Steady", + "publisher": "Reuters", + "link": "https://example.com/fed", + "summary": "The Federal Reserve decided to hold interest rates.", + } + ] + return mock_search + + def test_returns_string_with_articles(self): + """When yfinance Search returns articles, a formatted string is returned.""" + from tradingagents.dataflows.yfinance_news import get_global_news_yfinance + + with patch( + "tradingagents.dataflows.yfinance_news.yf.Search", + return_value=self._mock_search_with_article(), + ): + result = get_global_news_yfinance("2024-01-15", look_back_days=7) + + assert isinstance(result, str) + assert "Fed Holds Rates Steady" in result + + def test_no_news_returns_fallback_message(self): + """When no articles are found, a 'no news found' message is returned.""" + from tradingagents.dataflows.yfinance_news import get_global_news_yfinance + + mock_search = MagicMock() + mock_search.news = [] + + with patch( + "tradingagents.dataflows.yfinance_news.yf.Search", + return_value=mock_search, + ): + result = get_global_news_yfinance("2024-01-15") + + assert "No global news found" in result + + def test_handles_nested_content_structure(self): + """Articles with nested 'content' key are parsed correctly.""" + from tradingagents.dataflows.yfinance_news import get_global_news_yfinance + + mock_search = MagicMock() + mock_search.news = [ + { + "content": { + "title": "Inflation Report Beats Expectations", + "summary": "CPI data came in below forecasts.", + "provider": {"displayName": "Bloomberg"}, + "canonicalUrl": {"url": "https://bloomberg.com/story"}, + "pubDate": "2024-01-15T10:00:00Z", + } + } + ] + + with patch( + "tradingagents.dataflows.yfinance_news.yf.Search", + return_value=mock_search, + ): + result = get_global_news_yfinance("2024-01-15", look_back_days=3) + + assert "Inflation Report Beats Expectations" in result + + def test_deduplicates_articles_across_queries(self): + """Duplicate titles from multiple search queries appear only once.""" + from tradingagents.dataflows.yfinance_news import get_global_news_yfinance + + same_article = {"title": "Market Rally Continues", "publisher": "AP", "link": ""} + + mock_search = MagicMock() + mock_search.news = [same_article] + + with patch( + "tradingagents.dataflows.yfinance_news.yf.Search", + return_value=mock_search, + ): + result = get_global_news_yfinance("2024-01-15", look_back_days=7, limit=5) + + # Title should appear exactly once despite multiple search queries + assert result.count("Market Rally Continues") == 1