fix(cli): unify ticker handling with the data-path symbol normalizer

The CLI validated, normalized, and classified tickers with its own logic that
diverged from the data layer: it rejected '=' symbols like GC=F (#980),
classified BTCUSD as a stock (#981), and accepted unpriceable BTC-USDT (#982).
Route the CLI through normalize_symbol (now mapping USDT/USDC crypto quotes to
Yahoo's -USD pair), so validation, classification, and pricing agree.
This commit is contained in:
Yijia-Xiao
2026-06-13 20:50:21 +00:00
parent 7c8fe2fe9f
commit 76add9048f
3 changed files with 120 additions and 12 deletions

View File

@@ -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
# ``<BASE>-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 ``<BASE>-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: ``<BASE>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: