"""Symbol normalization and market-data error types for vendor calls. Yahoo Finance (the default vendor) uses specific ticker conventions that differ from the broker / TradingView / MT5 style symbols users often type: user types Yahoo wants why --------------- --------------- ----------------------------------- XAUUSD, XAUUSD+ GC=F gold has no forex pair on Yahoo; it is quoted as a COMEX future EURUSD EURUSD=X spot forex pairs take a ``=X`` suffix BTCUSD BTC-USD crypto pairs use a ``-`` separator SPX500, US500 ^GSPC index CFDs map to Yahoo index symbols Passing the raw broker symbol to Yahoo returns an empty result, which the agents previously received as free text and could hallucinate a price around (see issue #781). Centralizing the mapping here means every yfinance entry point resolves symbols the same way, and new instruments are added by appending a table row rather than editing call sites. """ from __future__ import annotations import logging import re logger = logging.getLogger(__name__) class NoMarketDataError(Exception): """Raised when a vendor returns no rows/records for a symbol. Carries both the symbol the user requested and the canonical symbol the vendor was actually queried with, so callers can build a clear message instead of emitting a vendor-specific empty string into the data channel. """ def __init__(self, symbol: str, canonical: str | None = None, detail: str = ""): self.symbol = symbol self.canonical = canonical or symbol self.detail = detail msg = f"No market data for {symbol!r}" if canonical and canonical != symbol: msg += f" (queried as {canonical!r})" if detail: msg += f": {detail}" super().__init__(msg) # ISO-4217 codes common enough to appear in retail forex pairs. A bare # six-letter symbol whose halves are BOTH in this set is treated as a spot # forex pair and given Yahoo's ``=X`` suffix. _FOREX_CURRENCIES = frozenset( { "USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "NZD", "CNY", "CNH", "HKD", "SGD", "SEK", "NOK", "DKK", "PLN", "MXN", "ZAR", "TRY", "INR", "KRW", "BRL", "RUB", "THB", } ) # Crypto bases that brokers quote against USD without a separator. _CRYPTO_BASES = frozenset( {"BTC", "ETH", "SOL", "XRP", "ADA", "DOGE", "LTC", "BCH", "DOT", "AVAX", "LINK"} ) # Explicit aliases for instruments whose broker symbol does not map to a # Yahoo symbol by rule. Metals/energy resolve to their front-month future; # index CFD names resolve to the underlying Yahoo index symbol. Extend by # adding rows — no call site changes required. _ALIASES = { # Precious metals (spot names -> COMEX/NYMEX futures) "XAUUSD": "GC=F", "XAU": "GC=F", "GOLD": "GC=F", "XAGUSD": "SI=F", "XAG": "SI=F", "SILVER": "SI=F", "XPTUSD": "PL=F", "XPDUSD": "PA=F", # Energy "WTICOUSD": "CL=F", "USOIL": "CL=F", "WTI": "CL=F", "BCOUSD": "BZ=F", "UKOIL": "BZ=F", "BRENT": "BZ=F", "NATGAS": "NG=F", "XNGUSD": "NG=F", "COPPER": "HG=F", "XCUUSD": "HG=F", # Index CFDs -> Yahoo index symbols "SPX500": "^GSPC", "US500": "^GSPC", "SPX": "^GSPC", "NAS100": "^NDX", "US100": "^NDX", "USTEC": "^NDX", "US30": "^DJI", "DJI30": "^DJI", "WS30": "^DJI", "GER40": "^GDAXI", "GER30": "^GDAXI", "DE40": "^GDAXI", "UK100": "^FTSE", "JP225": "^N225", "JPN225": "^N225", "FRA40": "^FCHI", "EU50": "^STOXX50E", "HK50": "^HSI", } # Yahoo symbols may contain letters, digits, and these structural characters. _YAHOO_SAFE = re.compile(r"^[A-Za-z0-9._\-\^=]+$") def normalize_symbol(raw: str) -> str: """Map a user/broker symbol to its canonical Yahoo Finance symbol. Resolution order (first match wins): 1. Explicit alias table (metals, energy, index CFDs). 2. Crypto rule: ``USD`` where BASE is a known crypto -> ``BASE-USD``. 3. Forex rule: six letters that are two ISO currency codes -> ``PAIR=X``. 4. Otherwise the upper-cased symbol is returned unchanged (plain equities, ETFs, Yahoo-native symbols like ``GC=F`` or ``^GSPC``). A trailing ``+`` (broker CFD marker, e.g. ``XAUUSD+``) is stripped before matching. The function is purely syntactic — it performs no network calls — so it is safe to apply on every request. """ if not isinstance(raw, str) or not raw.strip(): return raw s = raw.strip().upper() # Broker CFD/qualifier suffixes Yahoo never uses. s = s.rstrip("+") if s in _ALIASES: canonical = _ALIASES[s] elif len(s) == 6 and s[:3] in _CRYPTO_BASES and s[3:] == "USD": canonical = f"{s[:3]}-USD" elif s[:-3] in _CRYPTO_BASES and s.endswith("USD") and "-" not in s: canonical = f"{s[:-3]}-USD" elif len(s) == 6 and s[:3] in _FOREX_CURRENCIES and s[3:] in _FOREX_CURRENCIES: canonical = f"{s}=X" else: canonical = s if canonical != raw.strip().upper(): logger.info("Resolved symbol %r to Yahoo symbol %r", raw, canonical) return canonical def is_yahoo_safe(symbol: str) -> bool: """True when ``symbol`` only contains characters Yahoo symbols use.""" return bool(symbol) and _YAHOO_SAFE.fullmatch(symbol) is not None