#!/usr/bin/env python3 from __future__ import annotations """ 使用 ccxt 获取币安交易所所有 `*/USDT` 交易对最新 100 根 1 小时 K 线数据,并筛选出长期横盘的币种。 横盘判定基于以下三项指标(均可通过命令行参数调整): 1. 价格振幅占均价的比例(默认 ≤ 5%) 2. 收盘价线性回归斜率占均价的比例(默认 ≤ 0.05%) 3. 收盘价标准差占均价的比例(默认 ≤ 1.5%) 满足以上全部条件的交易对会被视为长期横盘。 """ import argparse import csv import logging import math import statistics import sys import time from dataclasses import dataclass from typing import Iterable, List, Optional, Sequence import ccxt # python ChanHeng.py --range-threshold 5 --slope-threshold 5 --std-threshold 0.015 DEFAULT_LIMIT = 100 DEFAULT_TIMEFRAME = "1h" STABLECOINS = { "USDT", "USDC", "BUSD", "TUSD", "USDP", "DAI", "FDUSD", "SUSD", "UST", "USTC", "EUR", "TRY", "BFUSD", "USDE", "XUSD", "USD1", "XUSD" } @dataclass class SidewaysMetrics: symbol: str price_range_pct: float slope_pct: float std_pct: float mean_close: float last_close: float data_points: int def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: parser = argparse.ArgumentParser( description="筛选币安长期横盘币种(默认 500 根 1 小时 K 线)" ) parser.add_argument( "--timeframe", default=DEFAULT_TIMEFRAME, help="K 线周期(默认:1h)", ) parser.add_argument( "--limit", type=int, default=DEFAULT_LIMIT, help="每个交易对获取的 K 线数量(默认:500)", ) parser.add_argument( "--range-threshold", type=float, default=0.05, help="最大价格振幅占均价比例阈值(默认:0.05,表示 5%%)", ) parser.add_argument( "--slope-threshold", type=float, default=0.0005, help="线性回归斜率占均价比例阈值(默认:0.0005,约 0.05%%)", ) parser.add_argument( "--std-threshold", type=float, default=0.015, help="标准差占均价比例阈值(默认:0.015,表示 1.5%%)", ) parser.add_argument( "--quote", action="append", default=[], help="只保留指定计价货币的交易对,可重复指定(示例:--quote USDT --quote FDUSD)", ) parser.add_argument( "--symbol", action="append", default=[], help="仅检测指定交易对,可重复(不指定则遍历所有符合条件的现货交易对)", ) parser.add_argument( "--max-symbols", type=int, default=None, help="限制最多检测的交易对数量(用于调试)", ) parser.add_argument( "--sleep", type=float, default=0.35, help="请求失败后的基础重试等待秒数(默认:0.35)", ) parser.add_argument( "--retries", type=int, default=3, help="单个交易对请求失败后的最大重试次数(默认:3)", ) parser.add_argument( "--include-inactive", action="store_true", help="包含已下架/不可交易的交易对(默认不包含)", ) parser.add_argument( "--export", type=str, default=None, help="将筛选结果导出为 CSV 文件的路径", ) parser.add_argument( "--verbose", action="store_true", help="输出更详细的日志信息", ) return parser.parse_args(argv) def setup_logging(verbose: bool) -> None: level = logging.DEBUG if verbose else logging.INFO logging.basicConfig( level=level, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) def create_exchange() -> ccxt.binance: exchange = ccxt.binance({"enableRateLimit": True}) exchange.options["defaultType"] = "spot" return exchange def iter_target_symbols( exchange: ccxt.binance, quotes: Sequence[str], includes: Sequence[str], include_inactive: bool, ) -> List[str]: markets = exchange.load_markets() filtered = [] quote_set = {quote.upper() for quote in quotes} include_set = {sym.upper() for sym in includes} for symbol, meta in markets.items(): if not meta.get("spot", False): continue if not include_inactive and meta.get("active") is False: continue normalized_symbol = symbol.upper() if include_set and normalized_symbol not in include_set: continue parts = symbol.split("/") if len(parts) != 2: continue base_asset, quote_asset = parts[0].upper(), parts[1].upper() target_quote = quote_set or {"USDT"} if quote_asset not in target_quote: continue if base_asset in STABLECOINS: continue filtered.append(symbol) filtered.sort() logging.info( "已筛选 %s 个目标交易对(quote 过滤:%s,专门列表:%s)", len(filtered), ",".join(sorted(quote_set or {"USDT"})), ",".join(sorted(include_set)) or "无", ) return filtered def fetch_ohlcv_with_retry( exchange: ccxt.binance, symbol: str, timeframe: str, limit: int, retries: int, base_sleep: float, ) -> List[List[float]]: attempt = 0 while True: try: return exchange.fetch_ohlcv(symbol, timeframe=timeframe, limit=limit) except ccxt.RateLimitExceeded as exc: wait_time = max(exchange.rateLimit / 1000.0 if exchange.rateLimit else 0, base_sleep) logging.debug("触发限频,等待 %.2f 秒后重试 %s:%s", wait_time, symbol, exc) time.sleep(wait_time) except (ccxt.NetworkError, ccxt.ExchangeError) as exc: attempt += 1 if attempt > retries: logging.warning("多次获取失败,跳过 %s:%s", symbol, exc) return [] wait_time = base_sleep * attempt logging.debug("请求失败,等待 %.2f 秒后重试 %s(第 %d 次):%s", wait_time, symbol, attempt, exc) time.sleep(wait_time) def linear_regression_slope(values: Sequence[float]) -> float: n = len(values) if n < 2: return 0.0 mean_x = (n - 1) / 2.0 mean_y = sum(values) / n numerator = 0.0 denominator = 0.0 for idx, value in enumerate(values): dx = idx - mean_x numerator += dx * (value - mean_y) denominator += dx * dx if denominator == 0: return 0.0 return numerator / denominator def compute_sideways_metrics(closes: Sequence[float], symbol: str) -> Optional[SidewaysMetrics]: if not closes: return None mean_close = sum(closes) / len(closes) if math.isclose(mean_close, 0.0): return None max_close = max(closes) min_close = min(closes) price_range_pct = (max_close - min_close) / mean_close slope = linear_regression_slope(closes) slope_pct = slope / mean_close std_dev = statistics.pstdev(closes) if len(closes) > 1 else 0.0 std_pct = std_dev / mean_close return SidewaysMetrics( symbol=symbol, price_range_pct=price_range_pct, slope_pct=slope_pct, std_pct=std_pct, mean_close=mean_close, last_close=closes[-1], data_points=len(closes), ) def is_sideways(metrics: SidewaysMetrics, range_threshold: float, slope_threshold: float, std_threshold: float) -> bool: return ( metrics.price_range_pct <= range_threshold and abs(metrics.slope_pct) <= slope_threshold and metrics.std_pct <= std_threshold ) def export_results(path: str, results: Sequence[SidewaysMetrics]) -> None: fieldnames = [ "symbol", "price_range_pct", "slope_pct", "std_pct", "mean_close", "last_close", "data_points", ] with open(path, "w", newline="", encoding="utf-8") as fp: writer = csv.DictWriter(fp, fieldnames=fieldnames) writer.writeheader() for item in results: writer.writerow( { "symbol": item.symbol, "price_range_pct": f"{item.price_range_pct:.6f}", "slope_pct": f"{item.slope_pct:.6f}", "std_pct": f"{item.std_pct:.6f}", "mean_close": f"{item.mean_close:.8f}", "last_close": f"{item.last_close:.8f}", "data_points": item.data_points, } ) logging.info("结果已导出至 %s", path) def run(argv: Optional[Sequence[str]] = None) -> int: args = parse_args(argv) if not args.quote: args.quote = ["USDT"] setup_logging(args.verbose) exchange = create_exchange() symbols = iter_target_symbols( exchange=exchange, quotes=args.quote, includes=args.symbol, include_inactive=args.include_inactive, ) if args.max_symbols is not None: symbols = symbols[: args.max_symbols] logging.info("出于调试目的,仅检测前 %d 个交易对。", len(symbols)) if not symbols: logging.error("未找到任何满足条件的交易对,请检查过滤条件。") return 1 sideways_results: List[SidewaysMetrics] = [] total = len(symbols) for idx, symbol in enumerate(symbols, start=1): logging.info("(%d/%d) 正在获取 %s 的 %s K 线(limit=%d)", idx, total, symbol, args.timeframe, args.limit) ohlcv = fetch_ohlcv_with_retry( exchange=exchange, symbol=symbol, timeframe=args.timeframe, limit=args.limit, retries=args.retries, base_sleep=args.sleep, ) if len(ohlcv) < max(100, args.limit // 2): logging.debug("交易对 %s 返回数据不足(%d 根),跳过。", symbol, len(ohlcv)) continue closes = [entry[4] for entry in ohlcv if entry[4] is not None] metrics = compute_sideways_metrics(closes, symbol) if not metrics: continue if is_sideways(metrics, args.range_threshold, args.slope_threshold, args.std_threshold): sideways_results.append(metrics) logging.info( "识别为横盘:%s | 振幅 %.2f%% | 斜率 %.4f%% | 标准差 %.2f%%", symbol, metrics.price_range_pct * 100, metrics.slope_pct * 100, metrics.std_pct * 100, ) else: logging.debug( "未满足条件:%s | 振幅 %.2f%% | 斜率 %.4f%% | 标准差 %.2f%%", symbol, metrics.price_range_pct * 100, metrics.slope_pct * 100, metrics.std_pct * 100, ) if not sideways_results: logging.warning("未检测到满足定义的长期横盘交易对。") return 0 sideways_results.sort(key=lambda item: (item.price_range_pct, abs(item.slope_pct), item.std_pct)) print("=" * 88) print( f"共识别 {len(sideways_results)} 个长期横盘交易对(阈值:振幅≤{args.range_threshold:.2%}," f"斜率≤{args.slope_threshold:.2%},标准差≤{args.std_threshold:.2%})" ) print("=" * 88) header = f"{'Symbol':15s} {'Range%':>10s} {'Slope%':>10s} {'STD%':>10s} {'Mean':>14s} {'Last':>14s} {'Count':>6s}" print(header) print("-" * len(header)) for item in sideways_results: print( f"{item.symbol:15s}" f" {item.price_range_pct * 100:10.4f}" f" {item.slope_pct * 100:10.4f}" f" {item.std_pct * 100:10.4f}" f" {item.mean_close:14.8f}" f" {item.last_close:14.8f}" f" {item.data_points:6d}" ) if args.export: export_results(args.export, sideways_results) logging.info("任务完成。") return 0 if __name__ == "__main__": sys.exit(run())