""" Pair Scanner for Multi-Pair Divergence Strategy. Generates all possible pairs from asset universe and checks tradeability. """ from dataclasses import dataclass from itertools import combinations from typing import Optional import ccxt from engine.logging_config import get_logger from .config import MultiPairConfig logger = get_logger(__name__) @dataclass class TradingPair: """ Represents a tradeable pair for spread analysis. Attributes: base_asset: First asset in the pair (numerator) quote_asset: Second asset in the pair (denominator) pair_id: Unique identifier for the pair is_direct: Whether pair can be traded directly on exchange exchange_symbol: Symbol for direct trading (if available) """ base_asset: str quote_asset: str pair_id: str is_direct: bool = False exchange_symbol: Optional[str] = None @property def name(self) -> str: """Human-readable pair name.""" return f"{self.base_asset}/{self.quote_asset}" def __hash__(self): return hash(self.pair_id) def __eq__(self, other): if not isinstance(other, TradingPair): return False return self.pair_id == other.pair_id class PairScanner: """ Scans and generates tradeable pairs from asset universe. Checks OKX for directly tradeable cross-pairs and generates synthetic pairs via USDT for others. """ def __init__(self, config: MultiPairConfig): self.config = config self.exchange: Optional[ccxt.Exchange] = None self._available_markets: set[str] = set() def _init_exchange(self) -> None: """Initialize exchange connection for market lookup.""" if self.exchange is None: exchange_class = getattr(ccxt, self.config.exchange_id) self.exchange = exchange_class({'enableRateLimit': True}) self.exchange.load_markets() self._available_markets = set(self.exchange.symbols) logger.info( "Loaded %d markets from %s", len(self._available_markets), self.config.exchange_id ) def generate_pairs(self, check_exchange: bool = True) -> list[TradingPair]: """ Generate all unique pairs from asset universe. Args: check_exchange: Whether to check OKX for direct trading Returns: List of TradingPair objects """ if check_exchange: self._init_exchange() pairs = [] assets = self.config.assets for base, quote in combinations(assets, 2): pair_id = f"{base}__{quote}" # Check if directly tradeable as cross-pair on OKX is_direct = False exchange_symbol = None if check_exchange: # Check perpetual cross-pair (e.g., ETH/BTC:BTC) # OKX perpetuals are typically quoted in USDT # Cross-pairs like ETH/BTC are less common cross_symbol = f"{base.replace('-USDT', '')}/{quote.replace('-USDT', '')}:USDT" if cross_symbol in self._available_markets: is_direct = True exchange_symbol = cross_symbol pair = TradingPair( base_asset=base, quote_asset=quote, pair_id=pair_id, is_direct=is_direct, exchange_symbol=exchange_symbol ) pairs.append(pair) # Log summary direct_count = sum(1 for p in pairs if p.is_direct) logger.info( "Generated %d pairs: %d direct, %d synthetic", len(pairs), direct_count, len(pairs) - direct_count ) return pairs def get_required_symbols(self, pairs: list[TradingPair]) -> list[str]: """ Get list of symbols needed to calculate all pair spreads. For synthetic pairs, we need both USDT pairs. For direct pairs, we still load USDT pairs for simplicity. Args: pairs: List of trading pairs Returns: List of unique symbols to load (e.g., ['BTC-USDT', 'ETH-USDT']) """ symbols = set() for pair in pairs: symbols.add(pair.base_asset) symbols.add(pair.quote_asset) return list(symbols) def filter_by_assets( self, pairs: list[TradingPair], exclude_assets: list[str] ) -> list[TradingPair]: """ Filter pairs that contain any of the excluded assets. Args: pairs: List of trading pairs exclude_assets: Assets to exclude Returns: Filtered list of pairs """ if not exclude_assets: return pairs exclude_set = set(exclude_assets) return [ p for p in pairs if p.base_asset not in exclude_set and p.quote_asset not in exclude_set ]