1410 lines
54 KiB
Python
1410 lines
54 KiB
Python
"""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.5 finnhub_news — _fetch_company_news_data helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestFetchCompanyNewsData:
|
|
"""_fetch_company_news_data handles errors by returning an empty list."""
|
|
|
|
_PATCH_TARGET = "tradingagents.dataflows.finnhub_news._make_api_request"
|
|
|
|
def test_fetch_company_news_data_api_error_returns_empty_list(self):
|
|
from tradingagents.dataflows.finnhub_news import _fetch_company_news_data
|
|
|
|
with patch(self._PATCH_TARGET, side_effect=Exception("API failure")):
|
|
result = _fetch_company_news_data("AAPL", {"symbol": "AAPL"})
|
|
|
|
assert result == []
|
|
|
|
def test_fetch_company_news_data_invalid_response_type_returns_empty_list(self):
|
|
from tradingagents.dataflows.finnhub_news import _fetch_company_news_data
|
|
|
|
with patch(self._PATCH_TARGET, return_value={"error": "Not a list"}):
|
|
result = _fetch_company_news_data("AAPL", {"symbol": "AAPL"})
|
|
|
|
assert 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
|
|
|
|
def test_invalid_transaction_values_fall_back_to_raw_strings(self):
|
|
"""Testing the error path at line 220 where float conversion fails."""
|
|
from tradingagents.dataflows.finnhub_news import get_insider_transactions
|
|
|
|
invalid_txn = {
|
|
"data": [
|
|
{
|
|
"name": "Test Exec",
|
|
"transactionCode": "P",
|
|
"share": "invalid_share",
|
|
"price": "invalid_price",
|
|
"value": "invalid_value",
|
|
"transactionDate": "2024-01-10",
|
|
"filingDate": "2024-01-12",
|
|
}
|
|
]
|
|
}
|
|
with patch(self._PATCH_TARGET, return_value=_json_response(invalid_txn)):
|
|
result = get_insider_transactions("AAPL")
|
|
|
|
assert "invalid_share" in result
|
|
assert "invalid_price" in result
|
|
assert "invalid_value" 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")
|