Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/findata/api/_b3_common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Shared B3 live-quote helpers for the REST and MCP layers.

``yfinance`` is imported lazily so the rest of the API keeps working without
the optional ``[b3]`` extra installed. Both ``routers/b3.py`` and ``mcp_app.py``
resolve the quotes module through here, so the install hint lives in one place.
"""

from __future__ import annotations

from typing import Any

from fastapi import HTTPException

MAX_TICKERS = 20


def resolve_quotes() -> Any:
"""Return the yfinance-backed quotes module, or raise 503 if the extra is missing."""
try:
from findata.sources.b3 import quotes
except ImportError as exc: # pragma: no cover - only without the [b3] extra
raise HTTPException(
status_code=503,
detail="B3 live quotes need the optional extra: pip install 'openfindata[b3]'",
) from exc
Comment on lines +17 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The missing-[b3] path no longer turns into the shared 503 response.

findata.sources.b3.quotes now defers the yfinance import to _import_yfinance() in src/findata/sources/b3/quotes.py:70-77, so from findata.sources.b3 import quotes succeeds even when the extra is absent. In that case this helper returns normally, and the later quote call raises RuntimeError, which the REST/MCP callers currently translate into 404/500 instead of the advertised 503 install hint.

Possible fix
+from importlib.util import find_spec
 from typing import Any
 
 from fastapi import HTTPException
@@
 def resolve_quotes() -> Any:
     """Return the yfinance-backed quotes module, or raise 503 if the extra is missing."""
+    if find_spec("yfinance") is None:
+        raise HTTPException(
+            status_code=503,
+            detail="B3 live quotes need the optional extra: pip install 'openfindata[b3]'",
+        )
     try:
         from findata.sources.b3 import quotes
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def resolve_quotes() -> Any:
"""Return the yfinance-backed quotes module, or raise 503 if the extra is missing."""
try:
from findata.sources.b3 import quotes
except ImportError as exc: # pragma: no cover - only without the [b3] extra
raise HTTPException(
status_code=503,
detail="B3 live quotes need the optional extra: pip install 'openfindata[b3]'",
) from exc
from importlib.util import find_spec
from typing import Any
from fastapi import HTTPException
def resolve_quotes() -> Any:
"""Return the yfinance-backed quotes module, or raise 503 if the extra is missing."""
if find_spec("yfinance") is None:
raise HTTPException(
status_code=503,
detail="B3 live quotes need the optional extra: pip install 'openfindata[b3]'",
)
try:
from findata.sources.b3 import quotes
except ImportError as exc: # pragma: no cover - only without the [b3] extra
raise HTTPException(
status_code=503,
detail="B3 live quotes need the optional extra: pip install 'openfindata[b3]'",
) from exc
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/findata/api/_b3_common.py` around lines 17 - 25, The missing-[b3] path is
no longer caught by the ImportError in resolve_quotes(), because importing
findata.sources.b3.quotes now succeeds before yfinance is needed. Update
resolve_quotes() to verify the optional dependency at import time by triggering
the yfinance-backed path or calling the helper that loads it from quotes, and
keep raising the shared HTTPException 503 with the install hint when the extra
is absent. Use the existing resolve_quotes() helper and the lazy import flow in
findata.sources.b3.quotes to make sure callers always get the intended 503
instead of a later RuntimeError.

return quotes
Comment on lines +17 to +26

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The quotes module (src/findata/sources/b3/quotes.py) imports yfinance lazily inside its functions rather than at the module level. As a result, from findata.sources.b3 import quotes will not raise an ImportError when yfinance is missing. This means resolve_quotes() will successfully return the module, and the missing dependency will only trigger a RuntimeError later when a quote is actually fetched. This leads to a 500 Internal Server Error in the MCP app or a misleading 404 Ticker not found in the REST router instead of the intended 503 Service Unavailable with the installation hint.

To fix this, explicitly attempt to import yfinance inside the try block to correctly detect if the optional extra is missing.

Suggested change
def resolve_quotes() -> Any:
"""Return the yfinance-backed quotes module, or raise 503 if the extra is missing."""
try:
from findata.sources.b3 import quotes
except ImportError as exc: # pragma: no cover - only without the [b3] extra
raise HTTPException(
status_code=503,
detail="B3 live quotes need the optional extra: pip install 'openfindata[b3]'",
) from exc
return quotes
def resolve_quotes() -> Any:
"""Return the yfinance-backed quotes module, or raise 503 if the extra is missing."""
try:
import yfinance # noqa: F401
from findata.sources.b3 import quotes
except ImportError as exc: # pragma: no cover - only without the [b3] extra
raise HTTPException(
status_code=503,
detail="B3 live quotes need the optional extra: pip install 'openfindata[b3]'",
) from exc
return quotes

24 changes: 5 additions & 19 deletions src/findata/api/mcp_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field

from findata.api._b3_common import MAX_TICKERS, resolve_quotes
from findata.registry import lookup
from findata.sources.anbima import indices as anbima_src
from findata.sources.aneel import leiloes
Expand Down Expand Up @@ -66,7 +67,6 @@

router = APIRouter()

_MAX_TICKERS = 20
_MIN_YEAR_B3_COTAHIST = 1986 # B3 publishes COTAHIST since 1986
_RGF_MAX_PERIOD = 3 # RGF quadrimestre runs 1..3

Expand Down Expand Up @@ -384,16 +384,6 @@ async def cvm_structured_fund(
# ── B3: Bolsa ─────────────────────────────────────────────────────


def _b3_quotes() -> Any:
try:
from findata.sources.b3 import quotes
except ImportError as exc: # pragma: no cover, only without the [b3] extra
raise HTTPException(
503, "Live quotes need the optional extra: pip install 'openfindata[b3]'"
) from exc
return quotes


@router.get(
"/b3/quote",
operation_id="b3_quote",
Expand All @@ -408,12 +398,12 @@ async def b3_quote(
"""Current quote(s) from the optional yfinance-backed source. For canonical,
official end-of-day history use ``b3_cotahist`` instead.
"""
quotes = _b3_quotes()
quotes = resolve_quotes()
ticker_list = [t.strip() for t in tickers.split(",") if t.strip()]
if not ticker_list:
raise HTTPException(400, "at least one ticker is required")
if len(ticker_list) > _MAX_TICKERS:
raise HTTPException(400, f"max {_MAX_TICKERS} tickers per request")
if len(ticker_list) > MAX_TICKERS:
raise HTTPException(400, f"max {MAX_TICKERS} tickers per request")
if len(ticker_list) == 1:
return await quotes.get_quote(ticker_list[0])
return await quotes.get_multiple_quotes(ticker_list)
Expand Down Expand Up @@ -655,11 +645,7 @@ async def anbima_tool(
if dataset == "ettj":
return await anbima_src.get_ettj(data)
if dataset == "debentures":
rows = await anbima_src.get_debentures(data)
if emissor:
needle = emissor.upper()
rows = [r for r in rows if needle in r.emissor.upper()]
return rows[:limit]
return (await anbima_src.get_debentures(data, emissor=emissor))[:limit]
fam = anbima_src.IMAFamily(family) if family else None
return (await anbima_src.get_ima(fam))[:limit]

Expand Down
5 changes: 1 addition & 4 deletions src/findata/api/routers/anbima.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,7 @@ async def debentures(
limit: int = Query(default=500, ge=1, le=5000),
) -> list[anbima.DebentureQuote]:
"""Daily secondary-market quotes for outstanding debentures."""
rows = await anbima.get_debentures(data)
if emissor:
needle = emissor.upper()
rows = [r for r in rows if needle in r.emissor.upper()]
rows = await anbima.get_debentures(data, emissor=emissor)
return rows[:limit]


Expand Down
23 changes: 6 additions & 17 deletions src/findata/api/routers/b3.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,18 @@

from fastapi import APIRouter, HTTPException, Path, Query

from findata.api._b3_common import MAX_TICKERS, resolve_quotes
from findata.sources.b3 import cotahist, indices

router = APIRouter(prefix="/b3", tags=["B3 - Bolsa"])

_MAX_TICKERS_PER_REQUEST = 20
_CURRENT_YEAR = date.today().year


def _quotes() -> Any:
try:
from findata.sources.b3 import quotes
except ImportError as exc: # pragma: no cover — triggered only without yfinance
raise HTTPException(
status_code=503,
detail=("B3 support is disabled. Install with: pip install 'openfindata[b3]'"),
) from exc
return quotes


@router.get("/quote/{ticker}")
async def get_quote(ticker: str) -> Any:
"""Get current stock quote for a B3 ticker (e.g., PETR4, VALE3, WEGE3)."""
quotes = _quotes()
quotes = resolve_quotes()
try:
return await quotes.get_quote(ticker)
except HTTPException:
Expand All @@ -56,7 +45,7 @@ async def get_history(
),
) -> Any:
"""Get historical price data for a B3 stock."""
quotes = _quotes()
quotes = resolve_quotes()
try:
return await quotes.get_history(ticker, period, interval)
except HTTPException:
Expand All @@ -70,14 +59,14 @@ async def get_multiple_quotes(
tickers: str = Query(..., description="Comma-separated tickers (e.g., PETR4,VALE3,ITUB4)"),
) -> Any:
"""Get current quotes for multiple B3 tickers."""
quotes = _quotes()
quotes = resolve_quotes()
ticker_list = [t.strip() for t in tickers.split(",") if t.strip()]
if not ticker_list:
raise HTTPException(status_code=400, detail="At least one ticker is required")
if len(ticker_list) > _MAX_TICKERS_PER_REQUEST:
if len(ticker_list) > MAX_TICKERS:
raise HTTPException(
status_code=400,
detail=f"Max {_MAX_TICKERS_PER_REQUEST} tickers per request",
detail=f"Max {MAX_TICKERS} tickers per request",
)
try:
return await quotes.get_multiple_quotes(ticker_list)
Expand Down
7 changes: 6 additions & 1 deletion src/findata/sources/anbima/indices.py
Original file line number Diff line number Diff line change
Expand Up @@ -555,7 +555,9 @@ async def _fetch_anbima_txt(url: str, label: str, d: date) -> str | None:
# ── Debêntures (daily TXT) ───────────────────────────────────────


async def get_debentures(data_referencia: date | None = None) -> list[DebentureQuote]:
async def get_debentures(
data_referencia: date | None = None, emissor: str | None = None
) -> list[DebentureQuote]:
"""Daily secondary-market quotes for outstanding debentures."""
d = data_referencia or date.today()
ymd = d.strftime("%y%m%d")
Expand Down Expand Up @@ -597,6 +599,9 @@ async def get_debentures(data_referencia: date | None = None) -> list[DebentureQ
)
if not header_seen:
logger.warning("debentures file for %s had no recognisable header row", d)
if emissor:
needle = emissor.upper()
rows = [r for r in rows if needle in r.emissor.upper()]
return rows


Expand Down
Loading