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:
@@ -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())
|
||||
Reference in New Issue
Block a user