fix: support commodity/forex/crypto tickers and never invent prices (#781)

Analyzing a symbol Yahoo Finance does not recognize (e.g. XAUUSD+) could
produce an invented price instead of an error. The agent now either prices
the correct instrument or clearly reports that data is unavailable.

Ticker support:
- Commodities/forex/crypto resolve to the symbol Yahoo actually serves, so
  you can enter the common form and it just works:
    XAUUSD / XAUUSD+ / GOLD  -> GC=F   (gold)
    USOIL                    -> CL=F   (WTI crude)
    EURUSD                   -> EURUSD=X
    BTCUSD                   -> BTC-USD
    SPX500 / NAS100          -> ^GSPC / ^NDX
  Native Yahoo symbols (AAPL, GC=F, ^GSPC) keep working unchanged. New
  instruments are added by extending the alias table.

Reliability:
- Unknown or delisted symbols now return a clear "data unavailable" result
  the agent reports verbatim, instead of a value the model fills in.
- A failed fetch no longer leaves a broken symbol cached until the cache is
  cleared by hand.
This commit is contained in:
Yijia-Xiao committed 2026-05-31 22:38:47 +00:00
1 parent 2f85be624e
commit 1ff3f07a73
10 files changed
+460 -53

No files matched your search

+70 -38
View File
@@ -5,6 +5,7 @@ import pandas as pd
import yfinance as yf
import os
from .stockstats_utils import StockstatsUtils, _clean_dataframe, yf_retry, load_ohlcv, filter_financials_by_date
from .symbol_utils import normalize_symbol, NoMarketDataError
def get_YFin_data_online(
symbol: Annotated[str, "ticker symbol of the company"],
@@ -15,16 +16,19 @@ def get_YFin_data_online(
datetime.strptime(start_date, "%Y-%m-%d")
datetime.strptime(end_date, "%Y-%m-%d")
# Create ticker object
ticker = yf.Ticker(symbol.upper())
# Resolve broker/forex symbols to Yahoo's convention (XAUUSD+ -> GC=F).
canonical = normalize_symbol(symbol)
ticker = yf.Ticker(canonical)
# Fetch historical data for the specified date range
data = yf_retry(lambda: ticker.history(start=start_date, end=end_date))
# Check if data is empty
# Empty result means the symbol is unknown/delisted. Raise a typed error
# instead of returning prose: the routing layer turns it into a single
# unambiguous "no data" signal so the agent never fabricates a price.
if data.empty:
return (
f"No data found for symbol '{symbol}' between {start_date} and {end_date}"
raise NoMarketDataError(
symbol, canonical, f"no rows between {start_date} and {end_date}"
)
# Remove timezone info from index for cleaner output
@@ -40,8 +44,10 @@ def get_YFin_data_online(
# Convert DataFrame to CSV string
csv_string = data.to_csv()
# Add header information
header = f"# Stock data for {symbol.upper()} from {start_date} to {end_date}\n"
# Add header information; note the resolved symbol when it differs so the
# agent (and user) can see which instrument was actually priced.
label = canonical if canonical == symbol.upper() else f"{canonical} (from {symbol})"
header = f"# Stock data for {label} from {start_date} to {end_date}\n"
header += f"# Total records: {len(data)}\n"
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
@@ -162,7 +168,9 @@ def get_stock_stats_indicators_window(
ind_string = ""
for date_str, value in date_values:
ind_string += f"{date_str}: {value}\n"
except NoMarketDataError:
raise # Unknown/delisted symbol — let the router emit the sentinel
except Exception as e:
print(f"Error getting bulk stockstats data: {e}")
# Fallback to original implementation if bulk method fails
@@ -236,6 +244,8 @@ def get_stockstats_indicator(
indicator,
curr_date,
)
except NoMarketDataError:
raise # Unknown/delisted symbol — let the router emit the sentinel
except Exception as e:
print(
f"Error getting stockstats indicator data for indicator {indicator} on {curr_date}: {e}"
@@ -250,12 +260,13 @@ def get_fundamentals(
curr_date: Annotated[str, "current date (not used for yfinance)"] = None
):
"""Get company fundamentals overview from yfinance."""
canonical = normalize_symbol(ticker)
try:
ticker_obj = yf.Ticker(ticker.upper())
ticker_obj = yf.Ticker(canonical)
info = yf_retry(lambda: ticker_obj.info)
if not info:
return f"No fundamentals data found for symbol '{ticker}'"
raise NoMarketDataError(ticker, canonical, "no fundamentals returned")
fields = [
("Name", info.get("longName")),
@@ -293,11 +304,20 @@ def get_fundamentals(
if value is not None:
lines.append(f"{label}: {value}")
header = f"# Company Fundamentals for {ticker.upper()}\n"
# yfinance returns a stub dict (e.g. {"trailingPegRatio": None}) for
# unknown symbols, so `info` is truthy but every field is empty. Treat
# "no usable fields" as no data rather than emitting a bare header the
# agent might fabricate around.
if not lines:
raise NoMarketDataError(ticker, canonical, "no fundamental fields returned")
header = f"# Company Fundamentals for {canonical}\n"
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
return header + "\n".join(lines)
except NoMarketDataError:
raise
except Exception as e:
return f"Error retrieving fundamentals for {ticker}: {str(e)}"
@@ -308,8 +328,9 @@ def get_balance_sheet(
curr_date: Annotated[str, "current date in YYYY-MM-DD format"] = None
):
"""Get balance sheet data from yfinance."""
canonical = normalize_symbol(ticker)
try:
ticker_obj = yf.Ticker(ticker.upper())
ticker_obj = yf.Ticker(canonical)
if freq.lower() == "quarterly":
data = yf_retry(lambda: ticker_obj.quarterly_balance_sheet)
@@ -319,17 +340,19 @@ def get_balance_sheet(
data = filter_financials_by_date(data, curr_date)
if data.empty:
return f"No balance sheet data found for symbol '{ticker}'"
raise NoMarketDataError(ticker, canonical, "no balance sheet data")
# Convert to CSV string for consistency with other functions
csv_string = data.to_csv()
# Add header information
header = f"# Balance Sheet data for {ticker.upper()} ({freq})\n"
header = f"# Balance Sheet data for {canonical} ({freq})\n"
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
return header + csv_string
except NoMarketDataError:
raise
except Exception as e:
return f"Error retrieving balance sheet for {ticker}: {str(e)}"
@@ -340,8 +363,9 @@ def get_cashflow(
curr_date: Annotated[str, "current date in YYYY-MM-DD format"] = None
):
"""Get cash flow data from yfinance."""
canonical = normalize_symbol(ticker)
try:
ticker_obj = yf.Ticker(ticker.upper())
ticker_obj = yf.Ticker(canonical)
if freq.lower() == "quarterly":
data = yf_retry(lambda: ticker_obj.quarterly_cashflow)
@@ -351,17 +375,19 @@ def get_cashflow(
data = filter_financials_by_date(data, curr_date)
if data.empty:
return f"No cash flow data found for symbol '{ticker}'"
raise NoMarketDataError(ticker, canonical, "no cash flow data")
# Convert to CSV string for consistency with other functions
csv_string = data.to_csv()
# Add header information
header = f"# Cash Flow data for {ticker.upper()} ({freq})\n"
header = f"# Cash Flow data for {canonical} ({freq})\n"
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
return header + csv_string
except NoMarketDataError:
raise
except Exception as e:
return f"Error retrieving cash flow for {ticker}: {str(e)}"
@@ -372,8 +398,9 @@ def get_income_statement(
curr_date: Annotated[str, "current date in YYYY-MM-DD format"] = None
):
"""Get income statement data from yfinance."""
canonical = normalize_symbol(ticker)
try:
ticker_obj = yf.Ticker(ticker.upper())
ticker_obj = yf.Ticker(canonical)
if freq.lower() == "quarterly":
data = yf_retry(lambda: ticker_obj.quarterly_income_stmt)
@@ -383,17 +410,19 @@ def get_income_statement(
data = filter_financials_by_date(data, curr_date)
if data.empty:
return f"No income statement data found for symbol '{ticker}'"
raise NoMarketDataError(ticker, canonical, "no income statement data")
# Convert to CSV string for consistency with other functions
csv_string = data.to_csv()
# Add header information
header = f"# Income Statement data for {ticker.upper()} ({freq})\n"
header = f"# Income Statement data for {canonical} ({freq})\n"
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
return header + csv_string
except NoMarketDataError:
raise
except Exception as e:
return f"Error retrieving income statement for {ticker}: {str(e)}"
@@ -402,18 +431,21 @@ def get_insider_transactions(
ticker: Annotated[str, "ticker symbol of the company"]
):
"""Get insider transactions data from yfinance."""
canonical = normalize_symbol(ticker)
try:
ticker_obj = yf.Ticker(ticker.upper())
ticker_obj = yf.Ticker(canonical)
data = yf_retry(lambda: ticker_obj.insider_transactions)
# Empty is normal here (many valid symbols have no insider filings),
# so report it plainly rather than treating the symbol as invalid.
if data is None or data.empty:
return f"No insider transactions data found for symbol '{ticker}'"
return f"No insider transactions reported for symbol '{canonical}'"
# Convert to CSV string for consistency with other functions
csv_string = data.to_csv()
# Add header information
header = f"# Insider Transactions data for {ticker.upper()}\n"
header = f"# Insider Transactions data for {canonical}\n"
header += f"# Data retrieved on: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
return header + csv_string