Files
trade/engine/example/full_comparison.py
T
Rekey edc50e8809 feat: 新增2h/6h时间框架支持,策略重构为增量指标计算
- 数据层: build_aggregates_sql 新增 2h/6h 聚合视图,默认起始时间调整为 2017-05
- 模型层: KlineInterval 类型扩展 2h/6h,DataService 新增对应表名和毫秒映射
- 指标层: 新增 incremental.py 增量指标模块 (EmaInc/AtrInc/RsiInc/BbInc),O(1) per bar
- 策略重构: long_short.py 和 regime_all.py 从批量 ema/atr 迁移至增量指标,避免每 bar 重复全量计算
- regime 探测器: RegimeDetector3 改为增量 EMA200,detect() 接口简化
- 回测扩展: regime_timeframe_comparison 从 4h/1d 扩展至 2h/4h/6h/1d
- 新增示例: multi_strategy_report, vol_break_compare/periods, intraday_explore, top3_trades 等分析脚本
2026-06-13 19:30:25 +08:00

975 lines
42 KiB
Python
Raw 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.
"""
全维度策略对比回测 — 8策略 × 4币种 × 5时间级别 × 4数据量
策略均来自 Investopedia / BabyPips / TradingView 等知名交易社区,覆盖四大类:
- 趋势跟踪:海龟交易、超级趋势、三均线排列、EMA双均线多空
- 动量:MACD金叉死叉
- 波动率突破:布林收缩爆发、ATR波动率突破
- 均值回归:RSI+布林带回归
用法:
source .venv/bin/activate && python example/full_comparison.py
"""
import asyncio
import json
import statistics
import sys
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional, Type
_project_root = Path(__file__).resolve().parent.parent.parent
if str(_project_root) not in sys.path:
sys.path.insert(0, str(_project_root))
from engine.common.base import BaseStrategy, Signal, StrategyConfig
from engine.common.models import Kline
from engine.common.config import config
from engine.backtest.models import BacktestConfig, BacktestResult
from engine.data import DataService
from engine.indicators.incremental import EmaInc, AtrInc, RsiInc, BbInc
from engine.example.long_short import LongShortEngine
# ── 全局常量 ──
SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
TIMEFRAMES = ["15m", "30m", "1h", "4h", "1d"]
INITIAL = 10_000.0
WARMUP = 150
MAX_CONCURRENCY = 6
# ── 回测时间段定义 ──
NOW = datetime.now(timezone.utc)
PERIODS = {
"全量": (None, None), # 由数据库查询决定
"近两年": (NOW - timedelta(days=730), NOW),
"近一年": (NOW - timedelta(days=365), NOW),
"近半年": (NOW - timedelta(days=182), NOW),
}
# ── 最小数据量要求(跳过数据不足的组合)──
MIN_BARS_FOR_PERIOD = {
"全量": 500,
"近两年": 200,
"近一年": 100,
"近半年": 50,
}
# ════════════════════════════════════════════════════════
# 策略 1:海龟交易 (Turtle Trading)
# Richard Dennis & William Eckhardt, 1983
# 20日高点突破入场,10日低点突破出场,2N ATR 止损
# ════════════════════════════════════════════════════════
class TurtleConfig(StrategyConfig):
entry_period: int = 20
exit_period: int = 10
atr_period: int = 20
atr_stop: float = 2.0
class TurtleStrategy(BaseStrategy):
strategy_type = "趋势跟踪"
strategy_desc = "Donchian 20/10通道突破 + 2N ATR止损,多空双向"
def __init__(self, c: TurtleConfig):
super().__init__(c)
self.cfg = c
self._highs: list[float] = []
self._lows: list[float] = []
self._closes: list[float] = []
self._atr = AtrInc(c.atr_period)
self._side: str = ""
self._entry_price: float = 0.0
self._highest_since: float = 0.0
self._lowest_since: float = float("inf")
async def on_kline(self, k: Kline) -> Optional[Signal]:
self._highs.append(k.high)
self._lows.append(k.low)
self._closes.append(k.close)
self._atr.update(k.high, k.low, k.close)
n = len(self._closes)
min_bars = max(self.cfg.entry_period, self.cfg.atr_period) + 5
if n < min_bars:
return None
ca = self._atr[-1]
if ca == 0:
return None
d_high = max(self._highs[-(self.cfg.entry_period + 1):-1])
d_low = min(self._lows[-(self.cfg.entry_period + 1):-1])
d_exit_high = max(self._highs[-(self.cfg.exit_period + 1):-1])
d_exit_low = min(self._lows[-(self.cfg.exit_period + 1):-1])
if self._side == "long":
self._highest_since = max(self._highest_since, k.high)
stop = self._entry_price - self.cfg.atr_stop * ca
trail = self._highest_since - self.cfg.atr_stop * ca * 0.5
if k.close < d_exit_low or k.close < max(stop, trail):
self._side = ""
return Signal(symbol=self.cfg.symbol, side="SELL", reason="海龟退出", timestamp=k.open_time, confidence=0.25)
elif self._side == "short":
self._lowest_since = min(self._lowest_since, k.low)
stop = self._entry_price + self.cfg.atr_stop * ca
trail = self._lowest_since + self.cfg.atr_stop * ca * 0.5
if k.close > d_exit_high or k.close > min(stop, trail):
self._side = ""
return Signal(symbol=self.cfg.symbol, side="BUY", reason="海龟退出", timestamp=k.open_time, confidence=0.25)
else:
margin = 0.002
if k.close > d_high * (1 + margin):
self._side = "long"
self._entry_price = k.close
self._highest_since = k.close
return Signal(symbol=self.cfg.symbol, side="BUY", reason="突破20日高", timestamp=k.open_time, confidence=0.25)
elif k.close < d_low * (1 - margin):
self._side = "short"
self._entry_price = k.close
self._lowest_since = k.close
return Signal(symbol=self.cfg.symbol, side="SELL", reason="跌破20日低", timestamp=k.open_time, confidence=0.25)
return None
# ════════════════════════════════════════════════════════
# 策略 2:超级趋势 (SuperTrend)
# Olivier Seban,广泛用于加密货币和商品
# ATR(10)×3 动态跟踪止损,趋势翻转即反转
# ════════════════════════════════════════════════════════
class SuperTrendConfig(StrategyConfig):
atr_period: int = 10
multiplier: float = 3.0
class SuperTrendStrategy(BaseStrategy):
strategy_type = "趋势跟踪"
strategy_desc = "ATR(10)×3倍动态跟踪止损带,趋势翻转即反转"
def __init__(self, c: SuperTrendConfig):
super().__init__(c)
self.cfg = c
self._atr = AtrInc(c.atr_period)
self._highs: list[float] = []
self._lows: list[float] = []
self._closes: list[float] = []
self._trend: int = 0
self._final_upper: float = 0.0
self._final_lower: float = 0.0
async def on_kline(self, k: Kline) -> Optional[Signal]:
self._highs.append(k.high)
self._lows.append(k.low)
self._closes.append(k.close)
self._atr.update(k.high, k.low, k.close)
n = len(self._closes)
if n < self.cfg.atr_period + 5:
return None
ca = self._atr[-1]
if ca == 0:
return None
hl2 = (k.high + k.low) / 2.0
upper = hl2 + self.cfg.multiplier * ca
lower = hl2 - self.cfg.multiplier * ca
prev_upper = self._final_upper
prev_lower = self._final_lower
prev_trend = self._trend
if k.close > prev_upper and prev_upper > 0:
self._trend = 1
elif k.close < prev_lower and prev_lower > 0:
self._trend = -1
if self._trend == 1:
self._final_lower = max(lower, prev_lower) if prev_lower > 0 else lower
self._final_upper = float("inf")
elif self._trend == -1:
self._final_upper = min(upper, prev_upper) if prev_upper > 0 else upper
self._final_lower = float("-inf")
else:
self._final_upper = upper
self._final_lower = lower
if prev_trend == self._trend:
return None
if self._trend == 1:
return Signal(symbol=self.cfg.symbol, side="BUY", reason="SuperTrend转多", timestamp=k.open_time, confidence=0.25)
elif self._trend == -1:
return Signal(symbol=self.cfg.symbol, side="SELL", reason="SuperTrend转空", timestamp=k.open_time, confidence=0.25)
return None
# ════════════════════════════════════════════════════════
# 策略 3MACD 金叉死叉
# Gerald Appel, 1970s
# MACD(12,26,9) 零轴附近金叉/死叉 + ATR 止损
# ════════════════════════════════════════════════════════
class MacdCrossConfig(StrategyConfig):
fast: int = 12
slow: int = 26
signal: int = 9
atr_period: int = 14
atr_stop: float = 2.0
class MacdCrossStrategy(BaseStrategy):
strategy_type = "动量"
strategy_desc = "MACD(12,26,9)零轴上金叉做多/零轴下死叉做空+ATR止损"
def __init__(self, c: MacdCrossConfig):
super().__init__(c)
self.cfg = c
self._ema_fast = EmaInc(c.fast)
self._ema_slow = EmaInc(c.slow)
self._atr = AtrInc(c.atr_period)
self._macd_vals: list[float] = []
self._signal_vals: list[float] = []
self._side: str = ""
self._entry_price: float = 0.0
self._bars_held: int = 0
async def on_kline(self, k: Kline) -> Optional[Signal]:
fe = self._ema_fast.update(k.close)
se = self._ema_slow.update(k.close)
self._atr.update(k.high, k.low, k.close)
n = len(self._ema_fast)
min_bars = max(self.cfg.slow, self.cfg.signal) + 10
if n < min_bars:
return None
macd = fe - se
self._macd_vals.append(macd)
if len(self._macd_vals) < self.cfg.signal + 2:
self._signal_vals.append(0.0)
return None
if len(self._signal_vals) < self.cfg.signal:
self._signal_vals.append(0.0)
if len(self._signal_vals) == self.cfg.signal:
self._signal_vals[-1] = sum(self._macd_vals[-self.cfg.signal:]) / self.cfg.signal
return None
k_sig = 2.0 / (self.cfg.signal + 1)
sig_val = macd * k_sig + self._signal_vals[-1] * (1 - k_sig)
self._signal_vals.append(sig_val)
if len(self._signal_vals) < 3:
return None
cur_m, cur_s = self._macd_vals[-1], self._signal_vals[-1]
prev_m, prev_s = self._macd_vals[-2], self._signal_vals[-2]
ca = self._atr[-1]
if ca == 0:
return None
golden = prev_m <= prev_s and cur_m > cur_s
death = prev_m >= prev_s and cur_m < cur_s
if self._side == "long":
self._bars_held += 1
stop = self._entry_price - self.cfg.atr_stop * ca
if k.close < stop or (death and self._bars_held > 3):
self._side = ""; self._bars_held = 0
return Signal(symbol=self.cfg.symbol, side="SELL", reason="MACD退出", timestamp=k.open_time, confidence=0.25)
elif self._side == "short":
self._bars_held += 1
stop = self._entry_price + self.cfg.atr_stop * ca
if k.close > stop or (golden and self._bars_held > 3):
self._side = ""; self._bars_held = 0
return Signal(symbol=self.cfg.symbol, side="BUY", reason="MACD退出", timestamp=k.open_time, confidence=0.25)
else:
if golden and cur_m > 0:
self._side = "long"; self._entry_price = k.close; self._bars_held = 0
return Signal(symbol=self.cfg.symbol, side="BUY", reason="MACD零轴上金叉", timestamp=k.open_time, confidence=0.25)
elif death and cur_m < 0:
self._side = "short"; self._entry_price = k.close; self._bars_held = 0
return Signal(symbol=self.cfg.symbol, side="SELL", reason="MACD零轴下死叉", timestamp=k.open_time, confidence=0.25)
return None
# ════════════════════════════════════════════════════════
# 策略 4:布林收缩爆发 (Bollinger Squeeze)
# John Bollinger, 2002
# BB在KC内部收缩→扩张突破入场
# ════════════════════════════════════════════════════════
class BBSqueezeConfig(StrategyConfig):
bb_period: int = 20
bb_std: float = 2.0
kc_period: int = 20
kc_mult: float = 1.5
squeeze_lookback: int = 30
atr_stop: float = 2.0
class BBSqueezeStrategy(BaseStrategy):
strategy_type = "波动率突破"
strategy_desc = "BB收缩至KC内部后扩张爆发,顺势入场 + ATR止损"
def __init__(self, c: BBSqueezeConfig):
super().__init__(c)
self.cfg = c
self._bb = BbInc(c.bb_period, c.bb_std)
self._ema = EmaInc(c.kc_period)
self._atr_kc = AtrInc(c.kc_period)
self._atr_stop = AtrInc(14)
self._closes: list[float] = []
self._side: str = ""
self._entry_price: float = 0.0
self._bb_widths: list[float] = []
self._kc_widths: list[float] = []
self._was_squeezed: bool = False
self._squeeze_bars: int = 0
async def on_kline(self, k: Kline) -> Optional[Signal]:
self._closes.append(k.close)
bb_u, bb_m, bb_l = self._bb.update(k.close)
typical = (k.high + k.low + k.close) / 3.0
kc_mid = self._ema.update(typical)
self._atr_kc.update(k.high, k.low, k.close)
self._atr_stop.update(k.high, k.low, k.close)
n = len(self._closes)
min_bars = max(self.cfg.bb_period, self.cfg.kc_period, self.cfg.squeeze_lookback) + 5
if n < min_bars:
return None
atr_kc = self._atr_kc[-1]
ca = self._atr_stop[-1]
if atr_kc == 0 or ca == 0 or bb_u == 0:
return None
kc_u = kc_mid + self.cfg.kc_mult * atr_kc
kc_l = kc_mid - self.cfg.kc_mult * atr_kc
bb_width = bb_u - bb_l
kc_width = kc_u - kc_l
self._bb_widths.append(bb_width)
self._kc_widths.append(kc_width)
is_squeezed = bb_u < kc_u and bb_l > kc_l
lookback = min(self.cfg.squeeze_lookback, len(self._bb_widths))
recent_bb_w = self._bb_widths[-lookback:]
min_bb_w = min(recent_bb_w)
width_squeeze = bb_width < min_bb_w * 1.2
was_squeezed = self._was_squeezed
fired = False
if is_squeezed:
self._was_squeezed = True
self._squeeze_bars += 1
elif self._was_squeezed:
self._was_squeezed = False
self._squeeze_bars = 0
fired = True
ema5 = sum(self._closes[-5:]) / 5.0 if n >= 5 else k.close
up_momentum = k.close > bb_m and k.close > ema5
down_momentum = k.close < bb_m and k.close < ema5
if self._side == "long":
stop = self._entry_price - self.cfg.atr_stop * ca
if k.close < stop or (down_momentum and not is_squeezed):
self._side = ""
return Signal(symbol=self.cfg.symbol, side="SELL", reason="BB退出", timestamp=k.open_time, confidence=0.25)
elif self._side == "short":
stop = self._entry_price + self.cfg.atr_stop * ca
if k.close > stop or (up_momentum and not is_squeezed):
self._side = ""
return Signal(symbol=self.cfg.symbol, side="BUY", reason="BB退出", timestamp=k.open_time, confidence=0.25)
else:
if was_squeezed and fired and width_squeeze:
if up_momentum:
self._side = "long"; self._entry_price = k.close
return Signal(symbol=self.cfg.symbol, side="BUY", reason="BB收缩爆发做多", timestamp=k.open_time, confidence=0.25)
elif down_momentum:
self._side = "short"; self._entry_price = k.close
return Signal(symbol=self.cfg.symbol, side="SELL", reason="BB收缩爆发做空", timestamp=k.open_time, confidence=0.25)
return None
# ════════════════════════════════════════════════════════
# 策略 5:三均线排列 (Triple EMA)
# 经典三线开花 — EMA(10,30,60) 多头/空头排列 + ATR 追踪止损
# ════════════════════════════════════════════════════════
class TripleEmaConfig(StrategyConfig):
fast: int = 10
mid: int = 30
slow: int = 60
atr_period: int = 14
atr_stop: float = 2.0
class TripleEmaStrategy(BaseStrategy):
strategy_type = "趋势跟踪"
strategy_desc = "EMA(10,30,60)多头/空头排列,快线金叉入场+ATR追踪止损"
def __init__(self, c: TripleEmaConfig):
super().__init__(c)
self.cfg = c
self._ema_fast = EmaInc(c.fast)
self._ema_mid = EmaInc(c.mid)
self._ema_slow = EmaInc(c.slow)
self._atr = AtrInc(c.atr_period)
self._side: str = ""
self._entry_price: float = 0.0
self._highest_since: float = 0.0
self._lowest_since: float = float("inf")
async def on_kline(self, k: Kline) -> Optional[Signal]:
self._ema_fast.update(k.close)
self._ema_mid.update(k.close)
self._ema_slow.update(k.close)
self._atr.update(k.high, k.low, k.close)
n = len(self._ema_slow)
if n < self.cfg.slow + 10:
return None
ef, em, es = self._ema_fast[-1], self._ema_mid[-1], self._ema_slow[-1]
pf, pm = self._ema_fast[-2], self._ema_mid[-2]
ca = self._atr[-1]
if ef == 0 or em == 0 or es == 0 or ca == 0:
return None
bull_align = ef > em > es
bear_align = ef < em < es
fast_cross_mid_up = pf <= pm and ef > em
fast_cross_mid_down = pf >= pm and ef < em
if self._side == "long":
self._highest_since = max(self._highest_since, k.high)
trail = self._highest_since - self.cfg.atr_stop * ca
if fast_cross_mid_down or k.close < trail:
self._side = ""
return Signal(symbol=self.cfg.symbol, side="SELL", reason="三均线退出", timestamp=k.open_time, confidence=0.25)
elif self._side == "short":
self._lowest_since = min(self._lowest_since, k.low)
trail = self._lowest_since + self.cfg.atr_stop * ca
if fast_cross_mid_up or k.close > trail:
self._side = ""
return Signal(symbol=self.cfg.symbol, side="BUY", reason="三均线退出", timestamp=k.open_time, confidence=0.25)
else:
if fast_cross_mid_up and bull_align:
self._side = "long"; self._entry_price = k.close; self._highest_since = k.close
return Signal(symbol=self.cfg.symbol, side="BUY", reason="三线多头排列", timestamp=k.open_time, confidence=0.25)
elif fast_cross_mid_down and bear_align:
self._side = "short"; self._entry_price = k.close; self._lowest_since = k.close
return Signal(symbol=self.cfg.symbol, side="SELL", reason="三线空头排列", timestamp=k.open_time, confidence=0.25)
return None
# ════════════════════════════════════════════════════════
# 策略 6RSI均值回归 (RSI Mean Reversion)
# 经典指标 — RSI(14)超买超卖 + 布林带确认 → 逆向交易
# ════════════════════════════════════════════════════════
class MeanRevConfig(StrategyConfig):
rsi_period: int = 14
rsi_oversold: float = 25.0
rsi_overbought: float = 75.0
bb_period: int = 20
bb_std: float = 2.0
atr_stop: float = 1.5
require_bb_touch: bool = True
class MeanRevStrategy(BaseStrategy):
strategy_type = "均值回归"
strategy_desc = "RSI(14)超卖25/超买75 + 布林带触碰确认 → 逆向回归"
def __init__(self, c: MeanRevConfig):
super().__init__(c)
self.cfg = c
self._rsi = RsiInc(c.rsi_period)
self._bb = BbInc(c.bb_period, c.bb_std)
self._atr = AtrInc(14)
self._side: str = ""
self._entry_price: float = 0.0
async def on_kline(self, k: Kline) -> Optional[Signal]:
r = self._rsi.update(k.close)
up, mid, lo = self._bb.update(k.close)
atr_v = self._atr.update(k.high, k.low, k.close)
if r == 0 or up == 0 or atr_v == 0:
return None
below_bb = k.close < lo if self.cfg.require_bb_touch else True
above_bb = k.close > up if self.cfg.require_bb_touch else True
if self._side == "long":
stop = self._entry_price - self.cfg.atr_stop * atr_v
take = self._entry_price + self.cfg.atr_stop * atr_v * 1.5
if k.close <= stop or k.close >= take or r > 55:
self._side = ""
reason = "止损" if k.close <= stop else ("止盈" if k.close >= take else "RSI回归中轨")
return Signal(symbol=self.cfg.symbol, side="SELL", reason=reason, timestamp=k.open_time)
elif self._side == "short":
stop = self._entry_price + self.cfg.atr_stop * atr_v
take = self._entry_price - self.cfg.atr_stop * atr_v * 1.5
if k.close >= stop or k.close <= take or r < 45:
self._side = ""
reason = "止损" if k.close >= stop else ("止盈" if k.close <= take else "RSI回归中轨")
return Signal(symbol=self.cfg.symbol, side="BUY", reason=reason, timestamp=k.open_time)
else:
if r < self.cfg.rsi_oversold and below_bb:
self._side = "long"; self._entry_price = k.close
return Signal(symbol=self.cfg.symbol, side="BUY", reason=f"RSI超卖{r:.0f}", timestamp=k.open_time)
elif r > self.cfg.rsi_overbought and above_bb:
self._side = "short"; self._entry_price = k.close
return Signal(symbol=self.cfg.symbol, side="SELL", reason=f"RSI超买{r:.0f}", timestamp=k.open_time)
return None
# ════════════════════════════════════════════════════════
# 策略 7ATR波动率突破 (Volatility Breakout)
# 经典波动率策略 — ATR收缩至极低后扩张 → 顺势突破
# ════════════════════════════════════════════════════════
class VolBreakConfig(StrategyConfig):
atr_period: int = 14
squeeze_period: int = 20
squeeze_ratio: float = 0.7
atr_stop: float = 2.0
class VolBreakStrategy(BaseStrategy):
strategy_type = "波动率突破"
strategy_desc = "ATR(14)收缩至极低后扩张突破 + EMA(10/30)方向确认"
def __init__(self, c: VolBreakConfig):
super().__init__(c)
self.cfg = c
self._atr = AtrInc(c.atr_period)
self._ema_fast = EmaInc(10)
self._ema_slow = EmaInc(30)
self._closes: list[float] = []
self._highs: list[float] = []
self._lows: list[float] = []
self._side: str = ""
self._entry_price: float = 0.0
self._was_squeezed = False
async def on_kline(self, k: Kline) -> Optional[Signal]:
self._closes.append(k.close)
self._highs.append(k.high)
self._lows.append(k.low)
self._atr.update(k.high, k.low, k.close)
self._ema_fast.update(k.close)
self._ema_slow.update(k.close)
n = len(self._closes)
if n < self.cfg.atr_period + self.cfg.squeeze_period:
return None
atr_now = self._atr[-1]
atr_prev = self._atr[-2] if n >= 2 else 0
ca = atr_now
if ca == 0:
return None
atr_window = [self._atr[i] for i in range(max(0, n - self.cfg.squeeze_period), n) if self._atr[i] > 0]
if not atr_window:
return None
min_atr = min(atr_window)
is_squeezed = atr_now < min_atr * (1 + (1 - self.cfg.squeeze_ratio))
atr_expanding = atr_now > atr_prev * 1.05 if atr_prev > 0 else False
cf, cs = self._ema_fast[-1], self._ema_slow[-1]
trend_up = cf > cs
if self._side == "long":
self._was_squeezed = False
stop = self._entry_price - self.cfg.atr_stop * ca
if k.close < stop or (cf < cs and not is_squeezed):
self._side = ""
return Signal(symbol=self.cfg.symbol, side="SELL", reason="ATR退出", timestamp=k.open_time)
elif self._side == "short":
self._was_squeezed = False
stop = self._entry_price + self.cfg.atr_stop * ca
if k.close > stop or (cf > cs and not is_squeezed):
self._side = ""
return Signal(symbol=self.cfg.symbol, side="BUY", reason="ATR退出", timestamp=k.open_time)
else:
if is_squeezed:
self._was_squeezed = True
elif self._was_squeezed and atr_expanding:
self._was_squeezed = False
if trend_up:
self._side = "long"; self._entry_price = k.close
return Signal(symbol=self.cfg.symbol, side="BUY", reason="ATR扩张突破做多", timestamp=k.open_time)
else:
self._side = "short"; self._entry_price = k.close
return Signal(symbol=self.cfg.symbol, side="SELL", reason="ATR扩张突破做空", timestamp=k.open_time)
return None
# ════════════════════════════════════════════════════════
# 策略 8EMA双均线多空 (EMA Crossover)
# 最经典的均线交叉 — 始终在场,金叉做多死叉做空
# ════════════════════════════════════════════════════════
class EmaCrossConfig(StrategyConfig):
fast: int = 10
slow: int = 50
atr_stop: float = 2.5
class EmaCrossStrategy(BaseStrategy):
strategy_type = "趋势跟踪"
strategy_desc = "EMA(10,50)金叉做多死叉做空 + ATR追踪止损,始终在场"
def __init__(self, c: EmaCrossConfig):
super().__init__(c)
self.cfg = c
self._ema_fast = EmaInc(c.fast)
self._ema_slow = EmaInc(c.slow)
self._atr = AtrInc(14)
self._closes: list[float] = []
self._highs: list[float] = []
self._lows: list[float] = []
self._highest: float = 0.0
self._lowest: float = float('inf')
self._position_side: str = ""
async def on_kline(self, k: Kline) -> Optional[Signal]:
self._closes.append(k.close)
self._highs.append(k.high)
self._lows.append(k.low)
self._ema_fast.update(k.close)
self._ema_slow.update(k.close)
self._atr.update(k.high, k.low, k.close)
n = len(self._closes)
if n < self.cfg.slow + 5:
return None
cur_f, cur_s = self._ema_fast[-1], self._ema_slow[-1]
cur_atr = self._atr[-1]
prev_f, prev_s = self._ema_fast[-2], self._ema_slow[-2]
if cur_f == 0 or cur_s == 0 or cur_atr == 0:
return None
golden = prev_f <= prev_s and cur_f > cur_s
death = prev_f >= prev_s and cur_f < cur_s
if self._position_side == "long":
self._highest = max(self._highest, k.high)
stop = self._highest - self.cfg.atr_stop * cur_atr
if death:
self._position_side = "short"
return Signal(symbol=self.cfg.symbol, side="SELL", reason="EMA死叉→做空", timestamp=k.open_time)
if k.close < stop:
self._position_side = ""
return Signal(symbol=self.cfg.symbol, side="SELL", reason="ATR止损→空仓", timestamp=k.open_time)
elif self._position_side == "short":
self._lowest = min(self._lowest, k.low)
stop = self._lowest + self.cfg.atr_stop * cur_atr
if golden:
self._position_side = "long"
return Signal(symbol=self.cfg.symbol, side="BUY", reason="EMA金叉→做多", timestamp=k.open_time)
if k.close > stop:
self._position_side = ""
return Signal(symbol=self.cfg.symbol, side="BUY", reason="ATR止损→空仓", timestamp=k.open_time)
else:
if golden:
self._position_side = "long"; self._highest = k.close
return Signal(symbol=self.cfg.symbol, side="BUY", reason="金叉→做多", timestamp=k.open_time)
elif death:
self._position_side = "short"; self._lowest = k.close
return Signal(symbol=self.cfg.symbol, side="SELL", reason="死叉→做空", timestamp=k.open_time)
return None
# ════════════════════════════════════════════════════════
# 策略注册表
# ════════════════════════════════════════════════════════
STRATEGY_REGISTRY = {
"1.海龟交易": {
"config_cls": TurtleConfig,
"strategy_cls": TurtleStrategy,
"make_config": lambda s: TurtleConfig(symbol=s, entry_period=20, exit_period=10, atr_period=20, atr_stop=2.0),
},
"2.超级趋势": {
"config_cls": SuperTrendConfig,
"strategy_cls": SuperTrendStrategy,
"make_config": lambda s: SuperTrendConfig(symbol=s, atr_period=10, multiplier=3.0),
},
"3.MACD金叉死叉": {
"config_cls": MacdCrossConfig,
"strategy_cls": MacdCrossStrategy,
"make_config": lambda s: MacdCrossConfig(symbol=s, fast=12, slow=26, signal=9, atr_period=14, atr_stop=2.0),
},
"4.布林收缩爆发": {
"config_cls": BBSqueezeConfig,
"strategy_cls": BBSqueezeStrategy,
"make_config": lambda s: BBSqueezeConfig(symbol=s, bb_period=20, bb_std=2.0, kc_period=20, kc_mult=1.5, squeeze_lookback=30, atr_stop=2.0),
},
"5.三均线排列": {
"config_cls": TripleEmaConfig,
"strategy_cls": TripleEmaStrategy,
"make_config": lambda s: TripleEmaConfig(symbol=s, fast=10, mid=30, slow=60, atr_period=14, atr_stop=2.0),
},
"6.RSI均值回归": {
"config_cls": MeanRevConfig,
"strategy_cls": MeanRevStrategy,
"make_config": lambda s: MeanRevConfig(symbol=s, rsi_period=14, rsi_oversold=25, rsi_overbought=75, bb_period=20, bb_std=2.0, atr_stop=1.5),
},
"7.ATR波动率突破": {
"config_cls": VolBreakConfig,
"strategy_cls": VolBreakStrategy,
"make_config": lambda s: VolBreakConfig(symbol=s, atr_period=14, squeeze_period=20, squeeze_ratio=0.7, atr_stop=2.0),
},
"8.EMA双均线多空": {
"config_cls": EmaCrossConfig,
"strategy_cls": EmaCrossStrategy,
"make_config": lambda s: EmaCrossConfig(symbol=s, fast=10, slow=50, atr_stop=2.5),
},
}
# ════════════════════════════════════════════════════════
# 参数用于表格的简洁呈现
# ════════════════════════════════════════════════════════
STRATEGY_PARAMS_STR = {
"1.海龟交易": "entry=20/exit=10/ATR(20)x2.0",
"2.超级趋势": "ATR(10)x3.0",
"3.MACD金叉死叉": "MACD(12,26,9)/ATR(14)x2.0",
"4.布林收缩爆发": "BB(20,2.0)/KC(20,1.5)/squeeze=30",
"5.三均线排列": "EMA(10,30,60)/ATR(14)x2.0",
"6.RSI均值回归": "RSI(14)25/75+BB(20,2.0)/ATR(14)x1.5",
"7.ATR波动率突破": "ATR(14)/squeeze=20x0.7/EMA(10,30)",
"8.EMA双均线多空": "EMA(10,50)/ATR(14)x2.5",
}
# ════════════════════════════════════════════════════════
# 执行
# ════════════════════════════════════════════════════════
async def run_one(entry, symbol, interval, period_label, start, end):
"""执行单次回测"""
make_config = entry["make_config"]
strategy_cls = entry["strategy_cls"]
sc = make_config(symbol)
bt = BacktestConfig(
symbol=symbol, interval=interval,
start_time=start, end_time=end,
initial_capital=INITIAL, warmup_bars=WARMUP,
)
engine = LongShortEngine(bt, db_config=config.db)
t0 = time.time()
try:
r = await engine.run(strategy_cls, sc)
elapsed = time.time() - t0
return r, elapsed, None
except Exception as ex:
elapsed = time.time() - t0
return None, elapsed, str(ex)
def safe(val, default=0):
"""安全取值,避免 None"""
return default if val is None else val
async def main():
# 第一步:预取所有数据范围
ds = DataService(config.db)
await ds.connect()
print("正在获取数据范围...")
date_ranges: dict[tuple[str, str], tuple] = {} # (symbol, interval) -> (start_dt, end_dt, bar_count_estimate)
for symbol in SYMBOLS:
for tf in TIMEFRAMES:
try:
s, e = await ds.fetch_symbol_date_range(symbol, tf)
# 粗略估计 bar 数量
bar_ms = {"15m": 900_000, "30m": 1_800_000, "1h": 3_600_000, "4h": 14_400_000, "1d": 86_400_000}
estimated_bars = int((e - s).total_seconds() * 1000 / bar_ms[tf])
date_ranges[(symbol, tf)] = (s, e, estimated_bars)
print(f" {symbol} {tf:<4}: {s.date()} ~ {e.date()} (约{estimated_bars:,}根)")
except Exception as ex:
print(f" {symbol} {tf:<4}: 获取失败 — {ex}")
await ds.close()
# 第二步:构建任务列表 (跳过数据不足的组合)
sem = asyncio.Semaphore(MAX_CONCURRENCY)
tasks_info: list[dict] = []
for strat_name, entry in STRATEGY_REGISTRY.items():
for symbol in SYMBOLS:
for tf in TIMEFRAMES:
key = (symbol, tf)
if key not in date_ranges:
continue
fs, fe, est_bars = date_ranges[key]
for period_label, (period_start, period_end) in PERIODS.items():
actual_start = period_start or fs
actual_end = period_end or fe
if actual_start >= actual_end:
continue
# 数据量检查
min_bars = MIN_BARS_FOR_PERIOD.get(period_label, 50)
actual_bars = est_bars
if period_label != "全量":
actual_bars = int((actual_end - actual_start).total_seconds() * 1000 / {
"15m": 900_000, "30m": 1_800_000, "1h": 3_600_000, "4h": 14_400_000, "1d": 86_400_000
}[tf])
if actual_bars < min_bars:
continue
# 跳过日线+近半年(bar太少)
if tf == "1d" and period_label == "近半年":
continue
tasks_info.append({
"strat_name": strat_name,
"entry": entry,
"symbol": symbol,
"tf": tf,
"period_label": period_label,
"start": actual_start,
"end": actual_end,
})
total = len(tasks_info)
print(f"\n{total} 组回测任务 (8策略×4币种×5时间×4数据量 - 跳过数据不足和日线近半年)")
# 第三步:并发执行
results: list[dict] = []
completed = 0
errors = 0
async def run_one_safe(info):
nonlocal completed, errors
async with sem:
r, elapsed, err = await run_one(
info["entry"], info["symbol"], info["tf"],
info["period_label"], info["start"], info["end"],
)
completed += 1
if err:
errors += 1
status = f"{err[:40]}"
elif r is None:
errors += 1
status = "✗ 无结果"
else:
m = r.metrics
status = f"{m.annual_return_pct:+.1f}%/yr"
print(f" [{completed}/{total}] {info['strat_name']} {info['symbol']} {info['tf']} {info['period_label']} ({elapsed:.1f}s) {status}", flush=True)
row = {
"策略名": info["strat_name"],
"币种": info["symbol"],
"时间级别": info["tf"],
"数据量": info["period_label"],
"策略类型": info["entry"]["strategy_cls"].strategy_type if r else "",
"策略参数": STRATEGY_PARAMS_STR.get(info["strat_name"], ""),
"策略描述": info["entry"]["strategy_cls"].strategy_desc if r else "",
"日期范围": f"{info['start'].date()}~{info['end'].date()}",
}
if r is not None:
m = r.metrics
row.update({
"初始资金": INITIAL,
"最终权益": round(m.final_equity, 2),
"总收益%": round(m.total_return_pct, 2),
"年化收益%": round(m.annual_return_pct, 2),
"夏普比率": round(m.sharpe_ratio, 2),
"最大回撤%": round(m.max_drawdown_pct, 2),
"胜率%": round(m.win_rate * 100, 2),
"盈亏比": round(m.profit_factor, 2),
"交易次数": m.total_trades,
"平均盈亏": round(m.avg_trade_pnl, 2),
"最佳盈亏": round(m.best_trade_pnl, 2),
"最差盈亏": round(m.worst_trade_pnl, 2),
"卡尔玛比率": round(m.calmar_ratio, 2),
"耗时s": round(elapsed, 1),
})
else:
row.update({
"初始资金": INITIAL,
"最终权益": 0,
"总收益%": 0,
"年化收益%": 0,
"夏普比率": 0,
"最大回撤%": 0,
"胜率%": 0,
"盈亏比": 0,
"交易次数": 0,
"平均盈亏": 0,
"最佳盈亏": 0,
"最差盈亏": 0,
"卡尔玛比率": 0,
"耗时s": round(elapsed, 1),
"错误": err or "未知错误",
})
results.append(row)
return row
t_total = time.time()
await asyncio.gather(*[run_one_safe(info) for info in tasks_info])
total_elapsed = time.time() - t_total
print(f"\n全部完成!成功 {total - errors}/{total},错误 {errors},总耗时 {total_elapsed:.0f}s")
# 第四步:打印完整表格
print()
print("" * 195)
print(" 全维度策略对比回测结果")
print("" * 195)
print()
# 按策略分组打印
for strat_name in STRATEGY_REGISTRY:
strat_results = [r for r in results if r["策略名"] == strat_name]
if not strat_results:
continue
first = strat_results[0]
print(f"{strat_name} | 类型: {first['策略类型']} | {first['策略描述']}")
print(f" 参数: {first['策略参数']}")
print(f" {'币种':<10} {'时间':<5} {'数据量':<6} {'总收益%':>8} {'年化%':>8} {'夏普':>7} {'回撤%':>7} {'胜率%':>7} {'盈亏比':>7} {'交易':>6} {'日期范围':<24}")
print(" " + "" * 185)
# 排序:币种、时间级别、数据量
strat_results.sort(key=lambda x: (SYMBOLS.index(x["币种"]), TIMEFRAMES.index(x["时间级别"]), list(PERIODS.keys()).index(x["数据量"])))
for r in strat_results:
print(f" {r['币种']:<10} {r['时间级别']:<5} {r['数据量']:<6} {r['总收益%']:>7.1f}% {r['年化收益%']:>7.1f}% {r['夏普比率']:>7.2f} {r['最大回撤%']:>7.1f}% {r['胜率%']:>6.1f}% {r['盈亏比']:>7.2f} {r['交易次数']:>6} {r['日期范围']:<24}")
print()
# 第五步:终极汇总 — 每种时间级别+数据量下的最佳策略
print("" * 195)
print(" ■ 终极汇总:每组(时间级别+数据量)下各币种最佳策略(按年化收益)")
print("" * 195)
print()
for tf in TIMEFRAMES:
for period_label in PERIODS:
subset = [r for r in results if r["时间级别"] == tf and r["数据量"] == period_label and r.get("总收益%", 0) != 0]
if not subset:
continue
subset.sort(key=lambda x: x.get("年化收益%", -9999), reverse=True)
print(f"{tf} | {period_label}")
print(f" {'排名':<5} {'策略名':<22} {'币种':<10} {'总收益%':>8} {'年化%':>8} {'夏普':>7} {'回撤%':>7} {'胜率%':>7} {'盈亏比':>7} {'交易':>6}")
print(" " + "" * 130)
for i, r in enumerate(subset[:5]):
marker = ["🥇", "🥈", "🥉", " 4", " 5"][i]
print(f" {marker:<5} {r['策略名']:<22} {r['币种']:<10} {r['总收益%']:>7.1f}% {r['年化收益%']:>7.1f}% {r['夏普比率']:>7.2f} {r['最大回撤%']:>7.1f}% {r['胜率%']:>6.1f}% {r['盈亏比']:>7.2f} {r['交易次数']:>6}")
print()
# 第六步:保存 JSON
output_file = _project_root / "engine" / "example" / "full_comparison_result.json"
with open(output_file, "w", encoding="utf-8") as f:
json.dump({
"config": {
"symbols": SYMBOLS,
"timeframes": TIMEFRAMES,
"periods": list(PERIODS.keys()),
"initial_capital": INITIAL,
"warmup_bars": WARMUP,
"total_tasks": total,
"total_errors": errors,
"elapsed_seconds": total_elapsed,
"run_time": datetime.now(timezone.utc).isoformat(),
},
"results": results,
}, f, ensure_ascii=False, indent=2, default=str)
print(f" 详细结果已保存至: {output_file}")
print()
print("" * 195)
if __name__ == "__main__":
asyncio.run(main())