Merge PR #1861: recognize digit-leading crypto bases

This commit is contained in:
Haozhe Wu committed 2026-10-15 16:53:33 +08:00
commit 70a2801426
5 files changed
+93 -9

No files matched your search

+1 -1
View File
@@ -119,7 +119,7 @@ def _normalize_symbol(code: str, market: str) -> str:
if market == "crypto":
if cleaned.endswith("/USDT"):
return cleaned.replace("/", "-")
if re.fullmatch(r"[A-Z]{2,}USDT", cleaned):
if re.fullmatch(r"(?!\d+USDT)[A-Z0-9]{2,}USDT", cleaned):
return f"{cleaned[:-4]}-USDT"
return cleaned
if market == "hk_equity":
+4 -4
View File
@@ -119,17 +119,17 @@ _MARKET_PATTERNS = [
# Vietnam equities: HOSE (VIC.VN). Tickers are three letters in practice;
# the class stays broad to admit fund certificates and ETF codes.
(re.compile(r"^[A-Z0-9]+\.VN$", re.I), "vietnam_equity"),
(re.compile(r"^[A-Z]+-USDT$", re.I), "crypto"),
(re.compile(r"^[A-Z]+/USDT$", re.I), "crypto"),
(re.compile(r"^(?=[A-Z0-9]*[A-Z])[A-Z0-9]+-USDT$", re.I), "crypto"),
(re.compile(r"^(?=[A-Z0-9]*[A-Z])[A-Z0-9]+/USDT$", re.I), "crypto"),
# yfinance's native crypto spelling (BTC-USD, ETH-USD). Distinct from
# USDT pairs only in the quote currency; both belong to CryptoEngine.
(re.compile(r"^[A-Z]+-USD$", re.I), "crypto"),
(re.compile(r"^(?=[A-Z0-9]*[A-Z])[A-Z0-9]+-USD$", re.I), "crypto"),
# Concatenated spot pairs (BTCUSDT, ETHUSDC) with no separator. Same
# quote-asset table the trade-journal parser uses; without it these fell
# through every pattern and got a_share rules (T+1, no shorting) on a
# perpetual. Bare metals/FX (XAUUSD) end in USD, not USDT/USDC/BUSD, so
# they still reach the forex whitelist below.
(re.compile(r"^[A-Z]{2,}(?:USDT|USDC|BUSD)$", re.I), "crypto"),
(re.compile(r"^(?!\d+(?:USDT|USDC|BUSD)$)[A-Z0-9]{2,}(?:USDT|USDC|BUSD)$", re.I), "crypto"),
# China futures: product+delivery.exchange (e.g. IF2406.CFFEX, rb2410.SHFE)
# Tushare suffix spellings (SHF/CZC/CFX/GFE) classify here too.
(
+3 -1
View File
@@ -115,7 +115,9 @@ def _run_card_data_sources(config: Dict[str, Any], loader: Any) -> List[str]:
# ─── Market detection (lightweight, for signal alignment only) ───
_CRYPTO_RE = _re.compile(r"^[A-Z]+-USDT$|^[A-Z]+/USDT$", _re.I)
_CRYPTO_RE = _re.compile(
r"^(?=[A-Z0-9]*[A-Z])[A-Z0-9]+-USDT$|^(?=[A-Z0-9]*[A-Z])[A-Z0-9]+/USDT$", _re.I
)
# Forex / metals in their explicit Yahoo notations, plus the bare-6-char
# whitelist for XAUUSD / XAGUSD / XPTUSD / XPDUSD and G10 currencies. Mirrors
# ``backtest.engines._market_hooks._MARKET_PATTERNS`` so the ffill-limit
+3 -3
View File
@@ -48,8 +48,8 @@ _SOURCE_PATTERNS = [
# Korea: KOSPI (005930.KS) / KOSDAQ (247540.KQ), 6-digit codes. Served by
# pykrx (KRX public data, no auth); registry falls back to Yahoo/yfinance.
(re.compile(r"^\d{6}\.(KS|KQ)$", re.I), "pykrx"),
(re.compile(r"^[A-Z]+-USDT$", re.I), "okx"),
(re.compile(r"^[A-Z]+/USDT$", re.I), "ccxt"),
(re.compile(r"^(?=[A-Z0-9]*[A-Z])[A-Z0-9]+-USDT$", re.I), "okx"),
(re.compile(r"^(?=[A-Z0-9]*[A-Z])[A-Z0-9]+/USDT$", re.I), "ccxt"),
# Iran: Nobitex IRT-quoted crypto pairs (BTCIRT / BTC-IRT). Toman-
# denominated public UDF endpoint, no auth; explicit-source only (not in
# the crypto fallback chain — non-IRT pairs would never resolve there).
@@ -89,7 +89,7 @@ def detect_source(code: str) -> str:
def normalize_crypto_symbol(code: str) -> str:
"""Normalize USDT spot aliases without changing the requested quote asset."""
cleaned = code.strip().upper()
match = re.fullmatch(r"([A-Z]{2,})[-/]?USDT", cleaned)
match = re.fullmatch(r"((?!\d+[-/]?USDT)[A-Z0-9]{2,})[-/]?USDT", cleaned)
return f"{match.group(1)}-USDT" if match else code
@@ -0,0 +1,82 @@
"""Crypto bases that start with a digit (1INCH, 1000PEPE) must stay crypto.
The USDT-pair patterns used ``[A-Z]+`` for the base asset, so ``1INCH-USDT``
matched nothing and fell through to the ``a_share`` default: T+1 settlement,
no shorting, CNY and the tushare source were applied to a crypto pair.
Digit-only codes are equities (A-share / HK) and must be unaffected.
"""
from __future__ import annotations
import pytest
from backtest.correlation import _normalize_symbol, infer_market
from backtest.engines._market_hooks import _detect_market
from backtest.engines.base import _CRYPTO_RE
from src.market_data import detect_source, normalize_crypto_symbol
DIGIT_LEADING_PAIRS = [
"1INCH-USDT",
"1INCH/USDT",
"1INCHUSDT",
"1000PEPE-USDT",
"1000PEPE/USDT",
"1000SHIBUSDT",
"1inch-usdt",
]
@pytest.mark.parametrize("code", DIGIT_LEADING_PAIRS)
def test_digit_leading_base_is_classified_as_crypto(code: str) -> None:
assert _detect_market(code.upper()) == "crypto"
assert infer_market(code) == "crypto"
@pytest.mark.parametrize("code", ["1INCH-USDT", "1INCH/USDT", "1000PEPE-USDT", "1000PEPE/USDT"])
def test_signal_alignment_regex_accepts_digit_leading_base(code: str) -> None:
assert _CRYPTO_RE.match(code)
@pytest.mark.parametrize(
("code", "source"),
[("1INCH-USDT", "okx"), ("1000PEPE-USDT", "okx"), ("1INCH/USDT", "ccxt")],
)
def test_digit_leading_base_selects_crypto_source(code: str, source: str) -> None:
assert detect_source(code) == source
@pytest.mark.parametrize(
("code", "expected"),
[
("1INCH/USDT", "1INCH-USDT"),
("1inch-usdt", "1INCH-USDT"),
("1INCHUSDT", "1INCH-USDT"),
("1000SHIBUSDT", "1000SHIB-USDT"),
("BTCUSDT", "BTC-USDT"),
],
)
def test_digit_leading_base_normalizes_to_dash_pair(code: str, expected: str) -> None:
assert normalize_crypto_symbol(code) == expected
assert _normalize_symbol(code, "crypto") == expected
@pytest.mark.parametrize(
("code", "market"),
[
("000001.SZ", "a_share"),
("600000", "a_share"),
("600000.SH", "a_share"),
("0700.HK", "hk_equity"),
("00700", "hk_equity"),
],
)
def test_digit_only_equity_codes_are_not_captured(code: str, market: str) -> None:
assert infer_market(code) == market
assert not _CRYPTO_RE.match(code)
@pytest.mark.parametrize("code", ["600000USDT", "000001-USDT", "000001/USDT", "000001-USD"])
def test_digit_only_base_is_never_crypto(code: str) -> None:
assert _detect_market(code) != "crypto"
assert not _CRYPTO_RE.match(code)
assert normalize_crypto_symbol(code) == code