diff --git a/cli/utils.py b/cli/utils.py index 013a2b34d..c65cc7e25 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -24,6 +24,17 @@ ANALYST_ORDER = [ CRYPTO_SUFFIXES = ("-USD", "-USDT", "-USDC", "-BTC", "-ETH") +def is_valid_ticker_input(value: str) -> bool: + """Whether a ticker entry is acceptable (charset + length). + + Allows the characters Yahoo symbols use, including ``=`` for futures/forex + like ``GC=F`` and ``EURUSD=X`` (#980), and ``^`` for indices. Empty input is + allowed (it defaults to SPY downstream). + """ + v = value.strip() + return not v or (all(ch.isalnum() or ch in "._-^=" for ch in v) and len(v) <= 32) + + def get_ticker() -> str: """Prompt the user to enter a ticker symbol, preserving exchange suffixes. @@ -34,9 +45,8 @@ def get_ticker() -> str: ticker = questionary.text( f"Enter ticker symbol (e.g. {TICKER_INPUT_EXAMPLES}):", validate=lambda x: ( - not x.strip() - or (all(ch.isalnum() or ch in "._-^" for ch in x.strip()) and len(x.strip()) <= 32) - or "Please enter a valid ticker symbol, e.g. AAPL, 000404.SZ, 0700.HK." + is_valid_ticker_input(x) + or "Please enter a valid ticker symbol, e.g. AAPL, 000404.SZ, 0700.HK, GC=F." ), style=questionary.Style( [ @@ -54,13 +64,26 @@ def get_ticker() -> str: def normalize_ticker_symbol(ticker: str) -> str: - """Normalize ticker input while preserving exchange suffixes.""" - return ticker.strip().upper() + """Resolve user input to its canonical Yahoo symbol (single source of truth). + + Delegates to the data layer's ``normalize_symbol`` so the symbol the CLI + passes through the pipeline is exactly the one the data path will price + (e.g. ``BTCUSD`` -> ``BTC-USD``, ``XAUUSD`` -> ``GC=F``). Falls back to the + plain upper-case if the data layer is unavailable. + """ + try: + from tradingagents.dataflows.symbol_utils import normalize_symbol + + return normalize_symbol(ticker) + except Exception: + return ticker.strip().upper() def detect_asset_type(ticker: str) -> AssetType: - normalized_ticker = ticker.strip().upper() - if normalized_ticker.endswith(CRYPTO_SUFFIXES): + """Classify on the canonical symbol so e.g. BTCUSD and BTC-USDT both read as + crypto (#981/#982), matching what the data path will actually fetch.""" + canonical = normalize_ticker_symbol(ticker) + if canonical.endswith(CRYPTO_SUFFIXES): return AssetType.CRYPTO return AssetType.STOCK diff --git a/tests/test_cli_symbol_handling.py b/tests/test_cli_symbol_handling.py new file mode 100644 index 000000000..e599a4e09 --- /dev/null +++ b/tests/test_cli_symbol_handling.py @@ -0,0 +1,62 @@ +"""CLI symbol validation/classification must agree with the data path. + +Regressions for #980 (validation rejected GC=F), #981 (BTCUSD misclassified as +stock), #982 (BTC-USDT accepted but unpriceable on Yahoo). +""" +import pytest + +from cli.models import AssetType +from cli.utils import detect_asset_type, is_valid_ticker_input, normalize_ticker_symbol +from tradingagents.dataflows.symbol_utils import normalize_symbol + + +# --- #982: stablecoin-quoted crypto normalizes to Yahoo's -USD pair --- +@pytest.mark.parametrize("raw,expected", [ + ("BTCUSD", "BTC-USD"), + ("BTCUSDT", "BTC-USD"), + ("BTC-USDT", "BTC-USD"), + ("BTC-USDC", "BTC-USD"), + ("ethusdt", "ETH-USD"), + # non-crypto must be untouched + ("AAPL", "AAPL"), + ("GC=F", "GC=F"), + ("600519.SS", "600519.SS"), + ("EURUSD", "EURUSD=X"), +]) +def test_normalize_symbol_crypto_and_passthrough(raw, expected): + assert normalize_symbol(raw) == expected + + +# --- #980: validation accepts Yahoo futures/forex symbols --- +@pytest.mark.parametrize("value,ok", [ + ("GC=F", True), + ("EURUSD=X", True), + ("AAPL", True), + ("0700.HK", True), + ("^GSPC", True), + ("", True), # empty -> defaults to SPY downstream + ("bad symbol!", False), # space + '!' rejected + ("A" * 40, False), # too long +]) +def test_ticker_input_validation(value, ok): + assert is_valid_ticker_input(value) is ok + + +# --- #981/#982: asset-type classified on the canonical symbol --- +@pytest.mark.parametrize("raw,expected", [ + ("BTCUSD", AssetType.CRYPTO), + ("BTC-USDT", AssetType.CRYPTO), + ("BTC-USD", AssetType.CRYPTO), + ("ETHUSD", AssetType.CRYPTO), + ("AAPL", AssetType.STOCK), + ("GC=F", AssetType.STOCK), + ("600519.SS", AssetType.STOCK), +]) +def test_detect_asset_type(raw, expected): + assert detect_asset_type(raw) == expected + + +def test_cli_normalize_delegates_to_data_layer(): + # CLI must produce the same canonical symbol the data path will price. + for raw in ("XAUUSD", "BTCUSD", "btc-usdt", "AAPL"): + assert normalize_ticker_symbol(raw) == normalize_symbol(raw) diff --git a/tradingagents/dataflows/symbol_utils.py b/tradingagents/dataflows/symbol_utils.py index 0036a8c41..9a8e61881 100644 --- a/tradingagents/dataflows/symbol_utils.py +++ b/tradingagents/dataflows/symbol_utils.py @@ -89,12 +89,36 @@ _ALIASES = { _YAHOO_SAFE = re.compile(r"^[A-Za-z0-9._\-\^=]+$") +# Crypto quote currencies that all map to Yahoo's USD pair. Yahoo lists only +# ``-USD`` (not the USDT/USDC stablecoin pairs), so a broker symbol quoted +# in any of these resolves to ``-USD`` (#982). Longest first so ``USDT``/``USDC`` +# match before the ``USD`` substring. +_CRYPTO_QUOTES = ("USDT", "USDC", "USD") + + +def _normalize_crypto(s: str) -> str | None: + """Return ``-USD`` if ``s`` is a known crypto quoted in USD/USDT/USDC. + + Accepts dashed or undashed forms: ``BTCUSD``, ``BTCUSDT``, ``BTC-USDT``, + ``BTC-USDC`` all resolve to ``BTC-USD``. Returns None otherwise. + """ + compact = s.replace("-", "") + for quote in _CRYPTO_QUOTES: + if compact.endswith(quote): + base = compact[: -len(quote)] + if base in _CRYPTO_BASES: + return f"{base}-USD" + break + return None + + 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``. + 2. Crypto rule: a known crypto base quoted in USD/USDT/USDC (dashed or + not) -> ``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``). @@ -110,12 +134,11 @@ def normalize_symbol(raw: str) -> str: # Broker CFD/qualifier suffixes Yahoo never uses. s = s.rstrip("+") + crypto = _normalize_crypto(s) 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 crypto is not None: + canonical = crypto elif len(s) == 6 and s[:3] in _FOREX_CURRENCIES and s[3:] in _FOREX_CURRENCIES: canonical = f"{s}=X" else: