* Initial plan * feat: Finnhub integration layer with review fixes — 100 offline tests, vendor routing, evaluation report Co-authored-by: aguzererler <6199053+aguzererler@users.noreply.github.com> * review: extract _MAX_ERROR_LEN constant for error message truncation Co-authored-by: aguzererler <6199053+aguzererler@users.noreply.github.com> * re-review: fix version downgrade (0.2.0→0.2.1), extract _safe_fmt helper for earnings formatting Co-authored-by: aguzererler <6199053+aguzererler@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: aguzererler <6199053+aguzererler@users.noreply.github.com>
This commit is contained in:
parent
26cd4c8b78
commit
d5fb0fdd94
|
|
@ -11,6 +11,7 @@ requires-python = ">=3.10"
|
|||
dependencies = [
|
||||
"langchain-core>=0.3.81",
|
||||
"backtrader>=1.9.78.123",
|
||||
"chainlit>=2.5.5",
|
||||
"langchain-anthropic>=0.3.15",
|
||||
"langchain-experimental>=0.3.4",
|
||||
"langchain-google-genai>=2.1.5",
|
||||
|
|
|
|||
|
|
@ -911,7 +911,7 @@ def _make_quote_side_effect(symbols_quotes: dict) -> callable:
|
|||
return side_effect
|
||||
|
||||
|
||||
class TestGetMarketMoversFinnnhub:
|
||||
class TestGetMarketMoversFinnhub:
|
||||
"""get_market_movers_finnhub returns a sorted markdown table."""
|
||||
|
||||
_RATE_PATCH = "tradingagents.dataflows.finnhub_scanner._rate_limited_request"
|
||||
|
|
@ -985,7 +985,7 @@ class TestGetMarketMoversFinnnhub:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetMarketIndicesFinnnhub:
|
||||
class TestGetMarketIndicesFinnhub:
|
||||
"""get_market_indices_finnhub builds a table of index levels."""
|
||||
|
||||
_RATE_PATCH = "tradingagents.dataflows.finnhub_scanner._rate_limited_request"
|
||||
|
|
@ -1040,7 +1040,7 @@ class TestGetMarketIndicesFinnnhub:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetSectorPerformanceFinnnhub:
|
||||
class TestGetSectorPerformanceFinnhub:
|
||||
"""get_sector_performance_finnhub returns sector ETF data."""
|
||||
|
||||
_RATE_PATCH = "tradingagents.dataflows.finnhub_scanner._rate_limited_request"
|
||||
|
|
@ -1080,7 +1080,7 @@ class TestGetSectorPerformanceFinnnhub:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetTopicNewsFinnnhub:
|
||||
class TestGetTopicNewsFinnhub:
|
||||
"""get_topic_news_finnhub maps topic strings to Finnhub categories."""
|
||||
|
||||
_RATE_PATCH = "tradingagents.dataflows.finnhub_scanner._rate_limited_request"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ the Finnhub free tier), and the core HTTP request helper used by all other
|
|||
finnhub_* modules.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time as _time
|
||||
|
|
@ -76,7 +75,7 @@ _call_timestamps: list[float] = []
|
|||
_RATE_LIMIT = 60 # calls per minute
|
||||
|
||||
|
||||
def _rate_limited_request(endpoint: str, params: dict, timeout: int = 30) -> dict:
|
||||
def _rate_limited_request(endpoint: str, params: dict, timeout: int = 30) -> dict | list:
|
||||
"""Make a rate-limited Finnhub API request.
|
||||
|
||||
Enforces the 60-calls-per-minute limit for the free tier using a sliding
|
||||
|
|
@ -89,7 +88,7 @@ def _rate_limited_request(endpoint: str, params: dict, timeout: int = 30) -> dic
|
|||
timeout: HTTP request timeout in seconds.
|
||||
|
||||
Returns:
|
||||
Parsed JSON response as a dict.
|
||||
Parsed JSON response as a dict or list.
|
||||
|
||||
Raises:
|
||||
FinnhubError subclass on any API or network error.
|
||||
|
|
@ -126,20 +125,23 @@ def _rate_limited_request(endpoint: str, params: dict, timeout: int = 30) -> dic
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_api_request(endpoint: str, params: dict, timeout: int = 30) -> dict:
|
||||
def _make_api_request(endpoint: str, params: dict, timeout: int = 30) -> dict | list:
|
||||
"""Make a Finnhub API request with proper error handling.
|
||||
|
||||
Calls ``https://finnhub.io/api/v1/{endpoint}`` and returns the parsed JSON
|
||||
body. The ``token`` parameter is injected automatically from the
|
||||
``FINNHUB_API_KEY`` environment variable.
|
||||
|
||||
Most endpoints return a JSON object (dict), but some (e.g. ``/company-news``,
|
||||
``/news``) return a JSON array (list).
|
||||
|
||||
Args:
|
||||
endpoint: Finnhub endpoint path without leading slash (e.g. "quote").
|
||||
params: Query parameters dict (do NOT include ``token`` here).
|
||||
timeout: HTTP request timeout in seconds.
|
||||
|
||||
Returns:
|
||||
Parsed JSON response as a dict.
|
||||
Parsed JSON response as a dict or list.
|
||||
|
||||
Raises:
|
||||
APIKeyInvalidError: Invalid or missing API key (HTTP 401 or env missing).
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ using the Finnhub REST API. Output formats mirror the Alpha Vantage
|
|||
equivalents where possible for consistent agent-facing data.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Literal
|
||||
|
||||
from .finnhub_common import (
|
||||
|
|
|
|||
|
|
@ -18,10 +18,6 @@ from typing import Annotated
|
|||
|
||||
from .finnhub_common import (
|
||||
FinnhubError,
|
||||
RateLimitError,
|
||||
ThirdPartyError,
|
||||
ThirdPartyParseError,
|
||||
ThirdPartyTimeoutError,
|
||||
_make_api_request,
|
||||
_now_str,
|
||||
_rate_limited_request,
|
||||
|
|
@ -31,6 +27,20 @@ from .finnhub_common import (
|
|||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Maximum length for error messages embedded in table cells / log lines
|
||||
_MAX_ERROR_LEN = 60
|
||||
|
||||
|
||||
def _safe_fmt(value, fmt: str = "${:.2f}", fallback: str = "N/A") -> str:
|
||||
"""Safely format a numeric value, returning *fallback* on None or bad types."""
|
||||
if value is None:
|
||||
return fallback
|
||||
try:
|
||||
return fmt.format(float(value))
|
||||
except (ValueError, TypeError):
|
||||
return str(value)
|
||||
|
||||
|
||||
# Representative S&P 500 large-caps used as the movers basket.
|
||||
# Sorted roughly by market-cap weight — first 50 cover the bulk of the index.
|
||||
_SP500_SAMPLE: list[str] = [
|
||||
|
|
@ -170,9 +180,8 @@ def get_market_movers_finnhub(
|
|||
if quote["current_price"] == 0 and quote["prev_close"] == 0:
|
||||
continue
|
||||
rows.append(quote)
|
||||
except (FinnhubError, RateLimitError, ThirdPartyError,
|
||||
ThirdPartyTimeoutError, ThirdPartyParseError) as exc:
|
||||
errors.append(f"{symbol}: {exc!s:.60}")
|
||||
except FinnhubError as exc:
|
||||
errors.append(f"{symbol}: {str(exc)[:_MAX_ERROR_LEN]}")
|
||||
|
||||
if not rows:
|
||||
raise FinnhubError(
|
||||
|
|
@ -250,9 +259,8 @@ def get_market_indices_finnhub() -> str:
|
|||
result += f"| {display_name} | {price_str} | {change_str} | {change_pct_str} |\n"
|
||||
success_count += 1
|
||||
|
||||
except (FinnhubError, RateLimitError, ThirdPartyError,
|
||||
ThirdPartyTimeoutError, ThirdPartyParseError) as exc:
|
||||
result += f"| {display_name} | Error | - | {exc!s:.40} |\n"
|
||||
except FinnhubError as exc:
|
||||
result += f"| {display_name} | Error | - | {str(exc)[:_MAX_ERROR_LEN]} |\n"
|
||||
|
||||
if success_count == 0:
|
||||
raise FinnhubError("All market index fetches failed.")
|
||||
|
|
@ -291,10 +299,9 @@ def get_sector_performance_finnhub() -> str:
|
|||
result += f"| {sector_name} | {etf} | {price_str} | {change_pct_str} |\n"
|
||||
success_count += 1
|
||||
|
||||
except (FinnhubError, RateLimitError, ThirdPartyError,
|
||||
ThirdPartyTimeoutError, ThirdPartyParseError) as exc:
|
||||
except FinnhubError as exc:
|
||||
last_error = exc
|
||||
result += f"| {sector_name} | {etf} | Error | {exc!s:.30} |\n"
|
||||
result += f"| {sector_name} | {etf} | Error | {str(exc)[:_MAX_ERROR_LEN]} |\n"
|
||||
|
||||
# If ALL sectors failed, raise so route_to_vendor can fall back
|
||||
if success_count == 0 and last_error is not None:
|
||||
|
|
@ -405,12 +412,13 @@ def get_earnings_calendar_finnhub(from_date: str, to_date: str) -> str:
|
|||
symbol = item.get("symbol", "N/A")
|
||||
company = item.get("company", "N/A")[:30]
|
||||
date = item.get("date", "N/A")
|
||||
eps_est = item.get("epsEstimate", None)
|
||||
eps_prior = item.get("epsPrior", None)
|
||||
rev_est = item.get("revenueEstimate", None)
|
||||
eps_est_s = f"${eps_est:.2f}" if eps_est is not None else "N/A"
|
||||
eps_prior_s = f"${eps_prior:.2f}" if eps_prior is not None else "N/A"
|
||||
rev_est_s = f"${float(rev_est)/1e9:.2f}B" if rev_est is not None else "N/A"
|
||||
eps_est_s = _safe_fmt(item.get("epsEstimate"))
|
||||
eps_prior_s = _safe_fmt(item.get("epsPrior"))
|
||||
rev_raw = item.get("revenueEstimate")
|
||||
rev_est_s = _safe_fmt(
|
||||
float(rev_raw) / 1e9 if rev_raw is not None else None,
|
||||
fmt="${:.2f}B",
|
||||
)
|
||||
lines.append(f"| {symbol} | {company} | {date} | {eps_est_s} | {eps_prior_s} | {rev_est_s} |")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue