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 等分析脚本
This commit is contained in:
Rekey
2026-06-13 19:30:25 +08:00
parent b5cdb41993
commit edc50e8809
20 changed files with 484544 additions and 34 deletions
+393
View File
@@ -0,0 +1,393 @@
"""
多策略多级别分类回测报告
日内交易 (30m/1h) 中线交易 (2h/4h/6h) 长线交易 (1d/1w)
策略:牛熊自适应 / MACD / EMA双均线 / RSI / 布林突破
币种:BTC / ETH / BNB / SOL
数据:日内近两年,中线+长线全量
用法:
source .venv/bin/activate && python example/multi_strategy_report.py
"""
import asyncio
import sys
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
_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 import BacktestEngine, BacktestConfig, BacktestResult
from engine.indicators import macd, ema, rsi, bollinger, atr
# ═══════════════════════════════════════════
# 策略定义
# ═══════════════════════════════════════════
# --- MACD ---
class MacdConfig(StrategyConfig):
fast: int = 12; slow: int = 26; signal: int = 9
class MacdStrategy(BaseStrategy):
strategy_type = "macd"
def __init__(self, c: MacdConfig):
super().__init__(c); self.cfg = c; self._c: list[float] = []
async def on_kline(self, k: Kline) -> Optional[Signal]:
self._c.append(k.close)
ml, sl, _ = macd(self._c, self.cfg.fast, self.cfg.slow, self.cfg.signal)
if len(ml) < 3 or ml[-1] == 0: return None
if ml[-2] <= sl[-2] and ml[-1] > sl[-1]:
return Signal(symbol=self.cfg.symbol, side="BUY", reason="MACD金叉", timestamp=k.open_time)
if ml[-2] >= sl[-2] and ml[-1] < sl[-1]:
return Signal(symbol=self.cfg.symbol, side="SELL", reason="MACD死叉", timestamp=k.open_time)
return None
# --- EMA双均线 ---
class EmaCrossConfig(StrategyConfig):
fast: int = 20; slow: int = 50
class EmaCrossStrategy(BaseStrategy):
strategy_type = "ema_cross"
def __init__(self, c: EmaCrossConfig):
super().__init__(c); self.cfg = c; self._c: list[float] = []
async def on_kline(self, k: Kline) -> Optional[Signal]:
self._c.append(k.close)
f = ema(self._c, self.cfg.fast); s = ema(self._c, self.cfg.slow)
if len(f) < 3 or f[-1] == 0 or s[-1] == 0: return None
if f[-2] <= s[-2] and f[-1] > s[-1]:
return Signal(symbol=self.cfg.symbol, side="BUY", reason="EMA金叉", timestamp=k.open_time)
if f[-2] >= s[-2] and f[-1] < s[-1]:
return Signal(symbol=self.cfg.symbol, side="SELL", reason="EMA死叉", timestamp=k.open_time)
return None
# --- RSI ---
class RsiConfig(StrategyConfig):
period: int = 14; oversold: float = 30.0; overbought: float = 70.0
class RsiStrategy(BaseStrategy):
strategy_type = "rsi"
def __init__(self, c: RsiConfig):
super().__init__(c); self.cfg = c; self._c: list[float] = []; self._in = False
async def on_kline(self, k: Kline) -> Optional[Signal]:
self._c.append(k.close)
v = rsi(self._c, self.cfg.period)[-1]
if v == 0: return None
if v < self.cfg.oversold and not self._in:
self._in = True
return Signal(symbol=self.cfg.symbol, side="BUY", reason=f"RSI超卖({v:.1f})", timestamp=k.open_time)
if v > self.cfg.overbought and self._in:
self._in = False
return Signal(symbol=self.cfg.symbol, side="SELL", reason=f"RSI超买({v:.1f})", timestamp=k.open_time)
return None
# --- 布林突破 ---
class BollConfig(StrategyConfig):
period: int = 20; std: float = 2.0
class BollStrategy(BaseStrategy):
strategy_type = "boll"
def __init__(self, c: BollConfig):
super().__init__(c); self.cfg = c; self._c: list[float] = []
async def on_kline(self, k: Kline) -> Optional[Signal]:
self._c.append(k.close)
upper, mid, lower = bollinger(self._c, self.cfg.period, self.cfg.std)
if len(upper) < 3 or mid[-1] == 0: return None
p, md = k.close, mid[-1]
pp, pm = self._c[-2], mid[-2]
if pp <= pm and p > md and upper[-1] > 0 and mid[-1] > mid[-2]:
return Signal(symbol=self.cfg.symbol, side="BUY", reason=f"突破BB中轨", timestamp=k.open_time)
if pp >= pm and p < md:
return Signal(symbol=self.cfg.symbol, side="SELL", reason=f"跌破BB中轨", timestamp=k.open_time)
return None
# --- 牛熊自适应 (多空双向) ---
class RegimeDetector:
def __init__(self):
self._ath = 0.0
def update_ath(self, price: float):
if price > self._ath: self._ath = price
def ema200_slope(self, closes, idx):
if idx < 210: return "unknown"
e200 = ema(closes, 200)
if e200[idx - 20] == 0: return "unknown"
slope = (e200[idx] - e200[idx - 20]) / e200[idx - 20]
if slope > 0.002: return "bull"
if slope < -0.002: return "bear"
return "sideways"
def price_vs_ema200(self, closes, idx):
if idx < 210: return "unknown"
e200 = ema(closes, 200)
return "bull" if closes[idx] > e200[idx] else "bear"
def ath_drawdown(self, closes, idx):
if self._ath == 0: return "unknown"
dd = (closes[idx] - self._ath) / self._ath
if dd > -0.15: return "bull"
if dd < -0.35: return "bear"
return "sideways"
def detect(self, closes, idx):
r1 = self.ema200_slope(closes, idx); r2 = self.price_vs_ema200(closes, idx); r3 = self.ath_drawdown(closes, idx)
b = sum(1 for r in [r1,r2,r3] if r=="bull"); br = sum(1 for r in [r1,r2,r3] if r=="bear")
if b >= 2: return "bull"
if br >= 2: return "bear"
return "sideways"
class RegimeConfig(StrategyConfig):
fast: int = 10; slow: int = 50; atr_stop: float = 2.5
class RegimeStrategy(BaseStrategy):
strategy_type = "regime"
def __init__(self, c: RegimeConfig):
super().__init__(c); self.cfg = c
self._c: list[float] = []; self._h: list[float] = []; self._l: list[float] = []
self._detector = RegimeDetector(); self._side = ""; self._hp = 0.0; self._lp = float('inf')
async def on_kline(self, k: Kline) -> Optional[Signal]:
self._c.append(k.close); self._h.append(k.high); self._l.append(k.low)
self._detector.update_ath(k.close)
n = len(self._c)
if n < 220: return None
regime = self._detector.detect(self._c, n - 1)
f = ema(self._c, self.cfg.fast); s = ema(self._c, self.cfg.slow)
a = atr(self._h, self._l, self._c, 14)
cf, cs, ca, pf, ps = f[-1], s[-1], a[-1], f[-2], s[-2]
if cf == 0 or cs == 0 or ca == 0: return None
golden = pf <= ps and cf > cs; death = pf >= ps and cf < cs
if self._side == "long":
self._hp = max(self._hp, k.high); stop = self._hp - self.cfg.atr_stop * ca
if death or k.close < stop or regime == "bear":
self._side = ""
return Signal(symbol=self.cfg.symbol, side="SELL", reason="平多", timestamp=k.open_time)
elif self._side == "short":
self._lp = min(self._lp, k.low); stop = self._lp + self.cfg.atr_stop * ca
if golden or k.close > stop or regime == "bull":
self._side = ""
return Signal(symbol=self.cfg.symbol, side="BUY", reason="平空", timestamp=k.open_time)
else:
if regime == "bull" and golden:
self._side = "long"; self._hp = k.close
return Signal(symbol=self.cfg.symbol, side="BUY", reason="牛市金叉", timestamp=k.open_time)
elif regime == "bear" and death:
self._side = "short"; self._lp = k.close
return Signal(symbol=self.cfg.symbol, side="SELL", reason="熊市死叉", timestamp=k.open_time)
return None
# ═══════════════════════════════════════════
# 注册
# ═══════════════════════════════════════════
STRATEGY_REGISTRY = {
"牛熊自适应": (RegimeStrategy, RegimeConfig, "regime"),
"MACD": (MacdStrategy, MacdConfig, "trend"),
"EMA双均线": (EmaCrossStrategy, EmaCrossConfig, "trend"),
"RSI超卖反弹": (RsiStrategy, RsiConfig, "reversal"),
"布林突破": (BollStrategy, BollConfig, "breakout"),
}
SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
REGIME_PARAMS = {
"BTCUSDT": (10, 50), "ETHUSDT": (10, 75), "BNBUSDT": (20, 50), "SOLUSDT": (30, 50),
}
CATEGORIES = {
"日内交易": {
"intervals": ["30m", "1h"],
"strategies": ["MACD", "EMA双均线", "RSI超卖反弹", "布林突破", "牛熊自适应"],
"data": "recent",
},
"中线交易": {
"intervals": ["2h", "4h", "6h"],
"strategies": ["牛熊自适应", "MACD", "EMA双均线", "RSI超卖反弹", "布林突破"],
"data": "full",
},
"长线交易": {
"intervals": ["1d", "1w"],
"strategies": ["牛熊自适应", "MACD", "EMA双均线"],
"data": "full",
},
}
RECENT_START = datetime(2024, 6, 1)
RECENT_END = datetime(2026, 6, 12)
FULL_DEFAULT = datetime(2017, 1, 1)
async def run_simple(symbol, interval, strategy_cls, strategy_cfg, start, end) -> BacktestResult | None:
"""使用 BacktestEngine(只做多)运行回测"""
bt = BacktestConfig(
symbol=symbol, interval=interval, start_time=start, end_time=end,
initial_capital=10_000.0, warmup_bars=100,
)
strategy_cfg.symbol = symbol
engine = BacktestEngine(bt, db_config=config.db)
return await engine.run(strategy_cls, strategy_cfg)
async def run_regime(symbol, interval, start, end) -> BacktestResult | None:
"""使用 LongShortEngine(多空双向)运行牛熊自适应策略"""
from engine.example.long_short import LongShortEngine
fast, slow = REGIME_PARAMS.get(symbol, (10, 50))
sc = RegimeConfig(symbol=symbol, fast=fast, slow=slow)
bt = BacktestConfig(
symbol=symbol, interval=interval, start_time=start, end_time=end,
initial_capital=10_000.0, warmup_bars=250,
)
engine = LongShortEngine(bt, db_config=config.db)
return await engine.run(RegimeStrategy, sc)
# ═══════════════════════════════════════════
# 主流程
# ═══════════════════════════════════════════
async def main():
sem = asyncio.Semaphore(2) # 并发控制
async def with_sem(coro):
async with sem:
return await coro
out: list[str] = []
def w(line=""):
out.append(line); print(line)
# 收集所有结果: (category, interval, symbol, strategy_name, result)
all_results: list[dict] = []
w("# 多策略多级别分类回测报告")
w()
w(f"> 生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}")
w(f"> 初始资金:10,000 USDT | 手续费:0.1% | 滑点:0.05%")
w(f"> 日内交易使用近两年数据 (2024.06-2026.06),中线/长线使用全量历史数据")
w()
for cat_name, cat_cfg in CATEGORIES.items():
intervals = cat_cfg["intervals"]
strategy_names = cat_cfg["strategies"]
use_full = cat_cfg["data"] == "full"
w(f"## {cat_name} ({'/'.join(intervals)})")
w()
for interval in intervals:
w(f"### {interval}")
w()
# 表头
cols = "| 币种 |"
sep = "|------|"
for sn in strategy_names:
cols += f" {sn} 收益% | {sn} 夏普 | {sn} 回撤% | {sn} 交易 | {sn} 胜率% |"
sep += "--------|------|------|------|------|"
w(cols)
w(sep)
for symbol in SYMBOLS:
row = f"| {symbol:<10} |"
tasks = []
for sn in strategy_names:
if sn == "牛熊自适应":
# 牛熊自适应使用 LongShortEngine
if use_full:
start, end = FULL_DEFAULT, RECENT_END
else:
start, end = RECENT_START, RECENT_END
tasks.append((sn, with_sem(run_regime(symbol, interval, start, end))))
else:
cls, cfg_cls, _ = STRATEGY_REGISTRY[sn]
if use_full:
start, end = FULL_DEFAULT, RECENT_END
else:
start, end = RECENT_START, RECENT_END
cfg = cfg_cls()
tasks.append((sn, with_sem(run_simple(symbol, interval, cls, cfg, start, end))))
# 并行执行当前币种的所有策略
results_list = await asyncio.gather(*[t[1] for t in tasks], return_exceptions=True)
for (sn, _), r in zip(tasks, results_list):
if isinstance(r, Exception):
row += f" ERR | — | — | — | — |"
print(f"{symbol} {interval} {sn}: {r}")
elif r is None:
row += f" N/A | — | — | — | — |"
else:
m = r.metrics
row += f" {m.total_return_pct:>+6.1f}% | {m.sharpe_ratio:>4.2f} | {m.max_drawdown_pct:>5.1f}% | {m.total_trades:>4} | {m.win_rate*100:>4.1f}% |"
all_results.append({
"category": cat_name, "interval": interval,
"symbol": symbol, "strategy": sn,
"return": m.total_return_pct, "sharpe": m.sharpe_ratio,
"dd": m.max_drawdown_pct, "trades": m.total_trades,
"win": m.win_rate, "pf": m.profit_factor,
})
w(row)
w()
# ═══════════════════════════════════════
# 汇总分析
# ═══════════════════════════════════════
w("---")
w()
w("## 汇总分析")
w()
for cat_name in CATEGORIES:
cat_results = [r for r in all_results if r["category"] == cat_name]
if not cat_results:
continue
w(f"### {cat_name} — 各币种最优策略")
w()
w("| 币种 | 最佳周期 | 最佳策略 | 总收益% | 夏普 | 回撤% | 交易 | 胜率% |")
w("|------|---------|---------|--------|------|------|------|------|")
for symbol in SYMBOLS:
candidates = [r for r in cat_results if r["symbol"] == symbol]
if not candidates:
continue
best = max(candidates, key=lambda x: x["sharpe"])
w(f"| {symbol} | {best['interval']} | {best['strategy']} | {best['return']:>+7.1f}% | {best['sharpe']:.2f} | {best['dd']:.1f}% | {best['trades']} | {best['win']*100:.1f}% |")
w()
# 全市场最优
w("### 全市场 TOP 10(按夏普排序)")
w()
w("| 排名 | 分类 | 币种 | 周期 | 策略 | 总收益% | 夏普 | 回撤% | 交易 | 胜率% |")
w("|------|------|------|------|------|--------|------|------|------|------|")
ranked = sorted(all_results, key=lambda x: x["sharpe"], reverse=True)
for i, r in enumerate(ranked[:10]):
w(f"| {i+1} | {r['category']} | {r['symbol']} | {r['interval']} | {r['strategy']} | {r['return']:>+7.1f}% | {r['sharpe']:.2f} | {r['dd']:.1f}% | {r['trades']} | {r['win']*100:.1f}% |")
w()
# 按策略类型汇总
w("### 各策略类型平均表现")
w()
w("| 策略 | 分类 | 平均收益% | 平均夏普 | 平均回撤% | 平均胜率% |")
w("|------|------|---------|---------|---------|---------|")
for sn in ["牛熊自适应", "MACD", "EMA双均线", "RSI超卖反弹", "布林突破"]:
for cat_name in CATEGORIES:
sr = [r for r in all_results if r["strategy"] == sn and r["category"] == cat_name]
if not sr:
continue
avg_ret = sum(r["return"] for r in sr) / len(sr)
avg_sh = sum(r["sharpe"] for r in sr) / len(sr)
avg_dd = sum(r["dd"] for r in sr) / len(sr)
avg_win = sum(r["win"] for r in sr) / len(sr)
w(f"| {sn} | {cat_name} | {avg_ret:>+7.1f}% | {avg_sh:.2f} | {avg_dd:.1f}% | {avg_win*100:.1f}% |")
w()
# 写出文件
out_path = Path(__file__).resolve().parent.parent / "backtest" / "MULTI_STRATEGY_REPORT.md"
with open(out_path, "w", encoding="utf-8") as f:
f.write("\n".join(out) + "\n")
print(f"\n✓ 报告已保存到: {out_path}")
if __name__ == "__main__":
asyncio.run(main())