Files
2026-07-14 07:31:13 +08:00

192 lines
8.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import time
from datetime import datetime, timezone
from typing import Iterable
import aiohttp
import aiohttp.resolver
import ccxt.async_support as ccxt
import pandas as pd
import logging
logger = logging.getLogger(__name__)
class BinanceFetcher:
def __init__(self, config: dict):
self.config = config
self.quote_currencies = self._resolve_quote_currencies(config)
self.exchange = self._build_exchange(config)
self._markets_meta: dict[str, dict] = {} # symbol -> market dict(含 onchainDate 等)
@staticmethod
def _resolve_quote_currencies(config: dict) -> list[str]:
"""优先取 quote_currencies 列表,回退到 quote_currency 单值。"""
base = config.get('base_filter', {})
quotes = base.get('quote_currencies')
if quotes:
return [q.upper() for q in quotes]
single = base.get('quote_currency')
return [single.upper()] if single else ['USDT']
@staticmethod
def _build_exchange(config: dict):
exchange_class = getattr(ccxt, config['exchange']['name'])
kwargs = {
'enableRateLimit': True,
'options': {'defaultType': config['exchange']['market']},
}
proxy = config['exchange'].get('proxy')
if proxy:
kwargs['httpsProxy'] = proxy
exchange = exchange_class(kwargs)
# 若需代理,注入自定义 aiohttp sessionThreadedResolver 绕过 aiodns DNS 失败)
if proxy:
resolver = aiohttp.resolver.ThreadedResolver()
connector = aiohttp.TCPConnector(resolver=resolver, enable_cleanup_closed=True)
exchange._custom_session = aiohttp.ClientSession(connector=connector)
exchange._own_session = False # 阻止 ccxt 在 open() 中重建 session
exchange.session = exchange._custom_session
return exchange
async def close(self):
await self.exchange.close()
custom = getattr(self.exchange, '_custom_session', None)
if custom and not custom.closed:
await custom.close()
# ------------------------------------------------------------------
# Phase 1: 流动性 + 上市天数过滤
# ------------------------------------------------------------------
async def get_liquid_symbols(self) -> list[str]:
"""按所有配置的报价币拉取并过滤。"""
logger.info("正在加载交易所市场信息...")
await self.exchange.load_markets()
self._markets_meta = dict(self.exchange.markets)
tickers = await self.exchange.fetch_tickers()
base_conf = self.config['base_filter']
min_listing_days = int(base_conf.get('min_listing_days', 0))
exclude_stablecoins = bool(base_conf.get('exclude_stablecoins', True))
stablecoin_map = base_conf.get('stablecoin_bases', {})
# 通用稳定币列表(用于报价币不在 stablecoin_map 时的回退)
default_stable = {'USDT', 'USDC', 'FDUSD', 'DAI', 'TUSD', 'BUSD', 'EUR', 'TRY', 'WBTC'}
# 按报价币解析成交额门槛 —— 未配置时沿用默认值
default_volume = float(base_conf['min_quote_volume_24h'])
volume_thresholds: dict[str, float] = {
q.upper(): float(v) for q, v in (base_conf.get('volume_thresholds') or {}).items()
}
for q in self.quote_currencies:
t = volume_thresholds.get(q, default_volume)
logger.info(f" 报价币 {q}: 成交额门槛 = {t:,.0f}")
valid: list[str] = []
for symbol, ticker in tickers.items():
market = self._markets_meta.get(symbol, {})
quote = market.get('quote')
base = market.get('base')
if quote not in self.quote_currencies:
continue
# 1. 成交额(按 quote 独立阈值)
qv = ticker.get('quoteVolume') or 0
threshold = volume_thresholds.get(quote.upper(), default_volume)
if qv < threshold:
continue
# 2. 价差
bid, ask = ticker.get('bid'), ticker.get('ask')
if bid and ask and bid > 0:
spread = (ask - bid) / bid * 100
if spread > base_conf['max_spread_pct']:
continue
# 3. 上市天数(币安 market.active 通常为 TrueonchainDate/listingDate 不一定存在)
if min_listing_days > 0 and not self._passes_listing_filter(market, min_listing_days):
continue
# 4. 稳定币过滤
if exclude_stablecoins:
banned = set(stablecoin_map.get(quote, default_stable))
if base in banned:
continue
valid.append(symbol)
logger.info(
f"基础流动性过滤完成:报价币={self.quote_currencies},剩余 {len(valid)} 个标的。"
)
return valid
@staticmethod
def _passes_listing_filter(market: dict, min_days: int) -> bool:
"""检查市场是否满足上市天数要求。"""
# 币安 markets 通常不直接返回 listing date;这里使用 active 字段做软校验
if not market.get('active', True):
return False
# 部分交易所/合约类型会返回 info.createdAt / info.listDate
info = market.get('info', {}) if isinstance(market, dict) else {}
ts_ms = info.get('onboardDate') or info.get('listDate') or info.get('createdAt')
if ts_ms is None:
# 拿不到日期,按通过处理(避免误杀)
return True
try:
ts_ms = int(ts_ms)
except (TypeError, ValueError):
return True
onboard = datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc)
elapsed = (datetime.now(tz=timezone.utc) - onboard).days
return elapsed >= min_days
# ------------------------------------------------------------------
# Phase 2: 批量 K 线拉取(带重试)
# ------------------------------------------------------------------
async def fetch_klines_batch(self, symbols: list[str]) -> dict[str, pd.DataFrame]:
timeframe = self.config['data']['timeframe']
limit = self.config['data']['lookback_candles']
concurrency = int(self.config['data'].get('max_concurrency', 15))
fetcher_conf = self.config.get('fetcher', {})
max_retries = int(fetcher_conf.get('max_retries', 3))
retry_delay = float(fetcher_conf.get('retry_delay', 1.0))
sem = asyncio.Semaphore(concurrency)
async def fetch_single(sym):
async with sem:
return sym, await self._fetch_with_retry(sym, timeframe, limit, max_retries, retry_delay)
logger.info(f"开始异步拉取 {len(symbols)} 个标的的 {timeframe} K线...")
results = await asyncio.gather(*(fetch_single(s) for s in symbols))
return {sym: df for sym, df in results if df is not None}
async def _fetch_with_retry(
self, symbol: str, timeframe: str, limit: int, max_retries: int, retry_delay: float
) -> pd.DataFrame | None:
last_err = None
for attempt in range(1, max_retries + 1):
try:
ohlcv = await self.exchange.fetch_ohlcv(symbol, timeframe=timeframe, limit=limit)
if not ohlcv:
raise ValueError("empty ohlcv response")
df = pd.DataFrame(
ohlcv, columns=['timestamp', 'open', 'high', 'low', 'close', 'volume']
)
df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms')
df.set_index('timestamp', inplace=True)
return df
except Exception as e:
last_err = e
logger.warning(f"[{symbol}] 第 {attempt}/{max_retries} 次拉取失败: {e}")
if attempt < max_retries:
await asyncio.sleep(retry_delay * attempt)
logger.error(f"[{symbol}] 拉取失败,已重试 {max_retries} 次,最终放弃。last_err={last_err}")
return None