415 lines
12 KiB
Python
415 lines
12 KiB
Python
#!/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())
|
||
|