refactor: 缠论引擎迁入 chan/ 分层解耦,指标外置
将核心结构、指标与分析拆到 chan/{core,indicators,analysis,pipeline};
根目录保留兼容 shim;strategies 改为从 chan 包导入;买卖点经 bsp_macd 与 MACD 接合。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+4
-413
@@ -1,414 +1,5 @@
|
||||
#!/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
|
||||
"""兼容 shim:请优先 from chan... 导入。本文件仅保持旧路径可用。"""
|
||||
import importlib
|
||||
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())
|
||||
|
||||
_impl = importlib.import_module("chan.analysis.ChanHeng")
|
||||
sys.modules[__name__] = _impl
|
||||
|
||||
Reference in New Issue
Block a user