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,226 @@
|
||||
"""
|
||||
读取 full_comparison_result.json 并生成多维度排序对比表
|
||||
|
||||
用法:
|
||||
source .venv/bin/activate && python example/analyze_comparison.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_project_root = Path(__file__).resolve().parent.parent.parent
|
||||
if str(_project_root) not in sys.path:
|
||||
sys.path.insert(0, str(_project_root))
|
||||
|
||||
JSON_PATH = _project_root / "engine" / "example" / "full_comparison_result.json"
|
||||
|
||||
with open(JSON_PATH, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
results = data["results"]
|
||||
cfg = data["config"]
|
||||
|
||||
# ── 通用排序列 ──
|
||||
METRICS = [
|
||||
"总收益%", "年化收益%", "夏普比率", "最大回撤%", "胜率%",
|
||||
"盈亏比", "交易次数", "平均盈亏", "最佳盈亏", "最差盈亏", "卡尔玛比率",
|
||||
]
|
||||
|
||||
# 哪些指标越大越好(正序),哪些越小越好(倒序)
|
||||
DESCENDING = {"总收益%", "年化收益%", "夏普比率", "胜率%", "盈亏比", "平均盈亏", "最佳盈亏", "卡尔玛比率"}
|
||||
ASCENDING = {"最大回撤%", "交易次数", "最差盈亏", "耗时s"}
|
||||
|
||||
|
||||
def sort_results(items, key, descending=True):
|
||||
"""排序,返回 top N"""
|
||||
return sorted(items, key=lambda x: x.get(key, -9999) if key in DESCENDING else -x.get(key, 9999), reverse=descending)
|
||||
|
||||
|
||||
def print_table(title, rows, fields, col_widths):
|
||||
"""打印格式化表格"""
|
||||
print()
|
||||
print("═" * len(title))
|
||||
print(title)
|
||||
print("═" * len(title))
|
||||
print()
|
||||
|
||||
# header
|
||||
header = ""
|
||||
for i, f in enumerate(fields):
|
||||
header += f" {f:<{col_widths[i]}}"
|
||||
print(header)
|
||||
|
||||
# separator
|
||||
sep = " " + "─" * (sum(col_widths) + sum(c - len(str(fields[i])) for i, c in enumerate(col_widths)))
|
||||
print(sep)
|
||||
|
||||
for r in rows:
|
||||
line = ""
|
||||
for i, f in enumerate(fields):
|
||||
val = r.get(f, "")
|
||||
if isinstance(val, float):
|
||||
if abs(val) >= 1000:
|
||||
val_str = f"{val:>{col_widths[i]}.0f}"
|
||||
elif abs(val) >= 100:
|
||||
val_str = f"{val:>{col_widths[i]}.1f}"
|
||||
elif abs(val) >= 10:
|
||||
val_str = f"{val:>{col_widths[i]}.2f}"
|
||||
else:
|
||||
val_str = f"{val:>{col_widths[i]}.3f}"
|
||||
else:
|
||||
val_str = f"{str(val):<{col_widths[i]}}"
|
||||
line += f" {val_str}"
|
||||
print(line)
|
||||
print()
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 表1:全局排名 — 按年化收益 Top 30
|
||||
# ════════════════════════════════════════════════════════
|
||||
top_annual = sort_results(results, "年化收益%", True)[:30]
|
||||
print_table(
|
||||
f" 全局排名 — 按年化收益 Top 30(共{len(results)}组) | 本金 {cfg['initial_capital']:,.0f} USDT",
|
||||
top_annual,
|
||||
fields=["策略名", "币种", "时间级别", "数据量", "总收益%", "年化收益%", "夏普比率", "最大回撤%", "胜率%", "盈亏比", "交易次数", "日期范围"],
|
||||
col_widths=[22, 10, 8, 8, 9, 9, 8, 8, 7, 7, 6, 24],
|
||||
)
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 表2:按夏普比率 Top 30
|
||||
# ════════════════════════════════════════════════════════
|
||||
top_sharpe = sort_results(results, "夏普比率", True)[:30]
|
||||
print_table(
|
||||
" 全局排名 — 按夏普比率 Top 30",
|
||||
top_sharpe,
|
||||
fields=["策略名", "币种", "时间级别", "数据量", "年化收益%", "夏普比率", "最大回撤%", "胜率%", "盈亏比", "交易次数", "日期范围"],
|
||||
col_widths=[22, 10, 8, 8, 9, 8, 8, 7, 7, 6, 24],
|
||||
)
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 表3:近半年+近一年(近期真实表现)按年化 Top 30
|
||||
# ════════════════════════════════════════════════════════
|
||||
recent = [r for r in results if r["数据量"] in ("近半年", "近一年")]
|
||||
recent_top = sort_results(recent, "年化收益%", True)[:30]
|
||||
print_table(
|
||||
" 近期现实表现 — 近半年+近一年 — 按年化收益 Top 30",
|
||||
recent_top,
|
||||
fields=["策略名", "币种", "时间级别", "数据量", "总收益%", "年化收益%", "夏普比率", "最大回撤%", "胜率%", "盈亏比", "交易次数", "日期范围"],
|
||||
col_widths=[22, 10, 8, 8, 9, 9, 8, 8, 7, 7, 6, 24],
|
||||
)
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 表4:全量数据(历史长期)按年化 Top 20
|
||||
# ════════════════════════════════════════════════════════
|
||||
full_data = [r for r in results if r["数据量"] == "全量"]
|
||||
full_top = sort_results(full_data, "年化收益%", True)[:20]
|
||||
print_table(
|
||||
" 全量数据 — 按年化收益 Top 20",
|
||||
full_top,
|
||||
fields=["策略名", "币种", "时间级别", "总收益%", "年化收益%", "夏普比率", "最大回撤%", "胜率%", "盈亏比", "交易次数", "日期范围"],
|
||||
col_widths=[22, 10, 8, 9, 9, 8, 8, 7, 7, 6, 24],
|
||||
)
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 表5:各策略在4h+1d上的近一年表现(最实用的中长线)
|
||||
# ════════════════════════════════════════════════════════
|
||||
mid_long = [r for r in recent if r["时间级别"] in ("4h", "1d")]
|
||||
mid_sorted = sort_results(mid_long, "年化收益%", True)[:30]
|
||||
print_table(
|
||||
" 中长线(4h/1d)近期表现 — 按年化收益 Top 30",
|
||||
mid_sorted,
|
||||
fields=["策略名", "币种", "时间级别", "数据量", "总收益%", "年化收益%", "夏普比率", "最大回撤%", "胜率%", "盈亏比", "交易次数", "日期范围"],
|
||||
col_widths=[22, 10, 8, 8, 9, 9, 8, 8, 7, 7, 6, 24],
|
||||
)
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 表6:策略×币种×时间级别 盈利能力矩阵(近一年年化)
|
||||
# ════════════════════════════════════════════════════════
|
||||
one_year = [r for r in results if r["数据量"] == "近一年"]
|
||||
|
||||
print()
|
||||
print("═" * 120)
|
||||
print(" 近一年盈利能力矩阵 — 策略 × 币种 × 时间级别(年化收益%)")
|
||||
print("═" * 120)
|
||||
|
||||
for tf in cfg["timeframes"]:
|
||||
tf_data = [r for r in one_year if r["时间级别"] == tf]
|
||||
if not tf_data:
|
||||
continue
|
||||
print(f"\n ▲ 时间级别: {tf}")
|
||||
print(f" {'策略名':<24}", end="")
|
||||
for sym in cfg["symbols"]:
|
||||
print(f" {sym:>12}", end="")
|
||||
print()
|
||||
print(" " + "─" * 80)
|
||||
|
||||
strategies_seen = set()
|
||||
for r in sort_results(tf_data, "年化收益%", True):
|
||||
if r["策略名"] not in strategies_seen:
|
||||
strategies_seen.add(r["策略名"])
|
||||
print(f" {r['策略名']:<24}", end="")
|
||||
for sym in cfg["symbols"]:
|
||||
match = [x for x in tf_data if x["策略名"] == r["策略名"] and x["币种"] == sym]
|
||||
if match:
|
||||
val = match[0]["年化收益%"]
|
||||
color = "+" if val > 0 else ""
|
||||
print(f" {color}{val:>11.1f}%", end="")
|
||||
else:
|
||||
print(f" {'—':>12}", end="")
|
||||
print()
|
||||
print()
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 表7:每组(时间级别+数据量)下的最佳策略
|
||||
# ════════════════════════════════════════════════════════
|
||||
print("═" * 160)
|
||||
print(" 每组(时间级别 + 数据量)下的最佳策略 — 按年化收益")
|
||||
print("═" * 160)
|
||||
print()
|
||||
|
||||
for tf in cfg["timeframes"]:
|
||||
for period in cfg["periods"]:
|
||||
subset = [r for r in results if r["时间级别"] == tf and r["数据量"] == period]
|
||||
if not subset:
|
||||
continue
|
||||
subset_sorted = sort_results(subset, "年化收益%", True)
|
||||
|
||||
print(f" ▲ {tf} | {period}")
|
||||
print(f" {'排名':<5} {'策略名':<24} {'币种':<10} {'总收益%':>9} {'年化%':>9} {'夏普':>7} {'回撤%':>8} {'胜率%':>7} {'盈亏比':>7} {'交易':>6} {'卡尔玛':>7} {'日期范围':<24}")
|
||||
print(" " + "─" * 155)
|
||||
for i, r in enumerate(subset_sorted[:8]):
|
||||
rank = ["🥇1", "🥈2", "🥉3", " 4", " 5", " 6", " 7", " 8"][i]
|
||||
print(f" {rank:<5} {r['策略名']:<24} {r['币种']:<10} {r['总收益%']:>8.1f}% {r['年化收益%']:>8.1f}% {r['夏普比率']:>7.2f} {r['最大回撤%']:>7.1f}% {r['胜率%']:>6.1f}% {r['盈亏比']:>7.2f} {r['交易次数']:>6} {r['卡尔玛比率']:>7.2f} {r['日期范围']:<24}")
|
||||
print()
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 表8:策略总览 — 每个策略在所有组合中的盈利比例
|
||||
# ════════════════════════════════════════════════════════
|
||||
print("═" * 120)
|
||||
print(" 策略胜率统计 — 每个策略在所有回测组合中的盈利比例")
|
||||
print("═" * 120)
|
||||
print()
|
||||
|
||||
strategy_stats = {}
|
||||
for r in results:
|
||||
sn = r["策略名"]
|
||||
if sn not in strategy_stats:
|
||||
strategy_stats[sn] = {"total": 0, "positive": 0, "positive_annual": 0, "sum_return": 0, "sum_sharpe": 0}
|
||||
strategy_stats[sn]["total"] += 1
|
||||
if r["总收益%"] > 0:
|
||||
strategy_stats[sn]["positive"] += 1
|
||||
if r["年化收益%"] > 0:
|
||||
strategy_stats[sn]["positive_annual"] += 1
|
||||
strategy_stats[sn]["sum_return"] += r["年化收益%"]
|
||||
strategy_stats[sn]["sum_sharpe"] += r["夏普比率"]
|
||||
|
||||
print(f" {'策略名':<24} {'总回测':>6} {'总收益>0':>9} {'年化>0':>8} {'平均年化%':>10} {'平均夏普':>8}")
|
||||
print(" " + "─" * 75)
|
||||
for sn, st in sorted(strategy_stats.items(), key=lambda x: x[1]["positive_annual"] / x[1]["total"], reverse=True):
|
||||
avg_ret = st["sum_return"] / st["total"]
|
||||
avg_sharpe = st["sum_sharpe"] / st["total"]
|
||||
pos_pct = st["positive_annual"] / st["total"] * 100
|
||||
print(f" {sn:<24} {st['total']:>6} {st['positive']:>8} ({st['positive']/st['total']*100:>5.1f}%) {st['positive_annual']:>7} ({pos_pct:>5.1f}%) {avg_ret:>9.1f}% {avg_sharpe:>8.2f}")
|
||||
|
||||
print()
|
||||
print("═" * 120)
|
||||
@@ -0,0 +1,974 @@
|
||||
"""
|
||||
全维度策略对比回测 — 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
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 策略 3:MACD 金叉死叉
|
||||
# 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
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 策略 6:RSI均值回归 (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
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 策略 7:ATR波动率突破 (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
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 策略 8:EMA双均线多空 (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())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,563 @@
|
||||
"""
|
||||
日内策略探索 — 4 种思路 (15m / 30m / 1h 全币种)
|
||||
|
||||
1. 均值回归:RSI超买超卖 + 布林带触碰,震荡市中做回归
|
||||
2. 多时间框架:4h 牛熊判定方向过滤 + 1h EMA交叉入场
|
||||
3. 波动率突破:ATR 收缩后扩张,顺势突破
|
||||
4. 成交量:OBV 背离 + VWAP 回归
|
||||
|
||||
用法:
|
||||
source .venv/bin/activate && python example/intraday_explore.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
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 BacktestConfig
|
||||
from engine.data import DataService
|
||||
from engine.indicators.incremental import EmaInc, AtrInc, RsiInc, BbInc
|
||||
from engine.example.long_short import LongShortEngine
|
||||
from engine.example.regime_all import RegimeDetector3, RegimeEmaConfig, RegimeEmaStrategy
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 策略 1:均值回归 — RSI + 布林带
|
||||
# ════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
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):
|
||||
"""RSI 极端 + 布林带确认 → 均值回归,ATR 止损"""
|
||||
|
||||
strategy_type = "mean_rev"
|
||||
|
||||
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 = "" # "long" / "short"
|
||||
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
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 策略 2:多时间框架 — 4h 方向 + 1h 入场
|
||||
# ════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class MultiTFConfig(StrategyConfig):
|
||||
fast: int = 20
|
||||
slow: int = 100
|
||||
atr_stop: float = 2.0
|
||||
# 4h 数据由策略内部自动加载
|
||||
|
||||
|
||||
class MultiTFStrategy(BaseStrategy):
|
||||
"""4h 牛熊判定方向过滤,1h EMA 交叉入场,只顺大势"""
|
||||
|
||||
strategy_type = "multi_tf"
|
||||
|
||||
def __init__(self, c: MultiTFConfig):
|
||||
super().__init__(c)
|
||||
self.cfg = c
|
||||
self._ema_fast = EmaInc(c.fast)
|
||||
self._ema_slow = EmaInc(c.slow)
|
||||
self._atr = AtrInc(14)
|
||||
self._side: str = ""
|
||||
self._hp: float = 0.0
|
||||
self._lp: float = float("inf")
|
||||
# 4h 牛熊判定 — 在 on_start 中加载
|
||||
self._regime_map: dict[int, str] = {} # timestamp_hour -> regime
|
||||
self._4h_loaded = False
|
||||
|
||||
async def on_start(self) -> None:
|
||||
"""加载 4h 数据并预计算牛熊判定"""
|
||||
await super().on_start()
|
||||
if self._4h_loaded:
|
||||
return
|
||||
try:
|
||||
ds = DataService(config.db)
|
||||
await ds.connect()
|
||||
try:
|
||||
klines_4h = await ds.fetch_klines(
|
||||
symbol=self.cfg.symbol, interval="4h",
|
||||
start_time=datetime(2017, 1, 1),
|
||||
end_time=datetime(2026, 12, 31),
|
||||
limit=1_000_000,
|
||||
)
|
||||
detector = RegimeDetector3()
|
||||
for k in klines_4h:
|
||||
detector.update(k.close)
|
||||
idx = len(detector._e200) - 1
|
||||
if idx >= 220:
|
||||
regime = detector.detect(k.close, idx)
|
||||
# 4h bar 覆盖的时间窗口(按小时取整)
|
||||
hour_key = int(k.open_time / 3_600_000)
|
||||
for h in range(4):
|
||||
self._regime_map[hour_key + h] = regime
|
||||
self._4h_loaded = True
|
||||
finally:
|
||||
await ds.close()
|
||||
except Exception:
|
||||
pass # 加载失败则不做过滤
|
||||
|
||||
def _get_regime(self, ts_ms: float) -> str:
|
||||
"""根据 1h bar 时间戳查找对应 4h 牛熊状态"""
|
||||
hour_key = int(ts_ms / 3_600_000)
|
||||
return self._regime_map.get(hour_key, "sideways")
|
||||
|
||||
async def on_kline(self, k: Kline) -> Optional[Signal]:
|
||||
self._ema_fast.update(k.close)
|
||||
self._ema_slow.update(k.close)
|
||||
self._atr.update(k.high, k.low, k.close)
|
||||
|
||||
n = len(self._ema_fast)
|
||||
if n < self.cfg.slow + 5:
|
||||
return None
|
||||
|
||||
cf, cs = self._ema_fast[-1], self._ema_slow[-1]
|
||||
pf, ps = self._ema_fast[-2], self._ema_slow[-2]
|
||||
ca = self._atr[-1]
|
||||
if cf == 0 or cs == 0 or ca == 0:
|
||||
return None
|
||||
|
||||
golden = pf <= ps and cf > cs
|
||||
death = pf >= ps and cf < cs
|
||||
regime = self._get_regime(k.open_time)
|
||||
|
||||
# ── 多头持仓 ──
|
||||
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:
|
||||
self._side = ""
|
||||
reason = "死叉" if death else "ATR止损"
|
||||
return Signal(symbol=self.cfg.symbol, side="SELL", reason=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:
|
||||
self._side = ""
|
||||
reason = "金叉" if golden else "ATR止损"
|
||||
return Signal(symbol=self.cfg.symbol, side="BUY", reason=reason, timestamp=k.open_time)
|
||||
|
||||
# ── 入场:必须顺4h方向 ──
|
||||
else:
|
||||
if golden and regime == "bull":
|
||||
self._side = "long"
|
||||
self._hp = k.close
|
||||
return Signal(symbol=self.cfg.symbol, side="BUY", reason="4h牛+金叉", timestamp=k.open_time)
|
||||
elif death and regime == "bear":
|
||||
self._side = "short"
|
||||
self._lp = k.close
|
||||
return Signal(symbol=self.cfg.symbol, side="SELL", reason="4h熊+死叉", timestamp=k.open_time)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 策略 3:波动率突破 — ATR 收缩扩张
|
||||
# ════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class VolBreakConfig(StrategyConfig):
|
||||
atr_period: int = 14
|
||||
squeeze_period: int = 20 # ATR 收缩回看窗口
|
||||
squeeze_ratio: float = 0.7 # 当前 ATR < 最低 ATR * ratio 时视为收缩
|
||||
atr_stop: float = 2.0
|
||||
|
||||
|
||||
class VolBreakStrategy(BaseStrategy):
|
||||
"""ATR 收缩到极致后扩张 → 顺势突破,ATR 止损"""
|
||||
|
||||
strategy_type = "vol_break"
|
||||
|
||||
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 收缩检测:当前 ATR 是否处于 squeeze_period 内的最低水平
|
||||
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 扩张信号
|
||||
atr_expanding = atr_now > atr_prev * 1.05 if atr_prev > 0 else False
|
||||
|
||||
# 趋势方向
|
||||
cf = self._ema_fast[-1]
|
||||
cs = 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
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 策略 4:成交量 — OBV 背离 + VWAP 回归
|
||||
# ════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class VolumeConfig(StrategyConfig):
|
||||
obv_lookback: int = 20 # OBV 背离检测窗口
|
||||
vwap_std: float = 2.0 # VWAP 偏离标准差倍数
|
||||
atr_stop: float = 2.0
|
||||
|
||||
|
||||
class VolumeStrategy(BaseStrategy):
|
||||
"""OBV 背离(价格新低但 OBV 未新低→看涨)+ VWAP 偏离回归"""
|
||||
|
||||
strategy_type = "volume"
|
||||
|
||||
def __init__(self, c: VolumeConfig):
|
||||
super().__init__(c)
|
||||
self.cfg = c
|
||||
self._closes: list[float] = []
|
||||
self._highs: list[float] = []
|
||||
self._lows: list[float] = []
|
||||
self._volumes: list[float] = []
|
||||
self._obv: list[float] = [] # 增量 OBV
|
||||
self._obv_val: float = 0.0
|
||||
self._atr = AtrInc(14)
|
||||
self._cum_pv: float = 0.0 # 累积 price*volume
|
||||
self._cum_vol: float = 0.0 # 累积 volume
|
||||
self._side: str = ""
|
||||
self._entry_price: float = 0.0
|
||||
|
||||
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._volumes.append(k.volume)
|
||||
self._atr.update(k.high, k.low, k.close)
|
||||
|
||||
# 增量 OBV
|
||||
n = len(self._closes)
|
||||
if n == 1:
|
||||
self._obv_val = k.volume
|
||||
else:
|
||||
if k.close > self._closes[-2]:
|
||||
self._obv_val += k.volume
|
||||
elif k.close < self._closes[-2]:
|
||||
self._obv_val -= k.volume
|
||||
self._obv.append(self._obv_val)
|
||||
|
||||
# 增量 VWAP
|
||||
typical = (k.high + k.low + k.close) / 3.0
|
||||
self._cum_pv += typical * k.volume
|
||||
self._cum_vol += k.volume
|
||||
vwap = self._cum_pv / self._cum_vol if self._cum_vol > 0 else k.close
|
||||
|
||||
if n < self.cfg.obv_lookback + 5:
|
||||
return None
|
||||
|
||||
ca = self._atr[-1]
|
||||
if ca == 0:
|
||||
return None
|
||||
|
||||
# OBV 背离检测:价格新低但 OBV 未新低 → 潜在反转
|
||||
lookback = self.cfg.obv_lookback
|
||||
price_window = self._closes[-lookback:]
|
||||
obv_window = self._obv[-lookback:]
|
||||
price_made_new_low = min(price_window) == price_window[-1]
|
||||
obv_not_new_low = min(obv_window) < obv_window[-1]
|
||||
obv_bull_div = price_made_new_low and obv_not_new_low
|
||||
|
||||
# OBV 负背离:价格新高但 OBV 未新高
|
||||
price_made_new_high = max(price_window) == price_window[-1]
|
||||
obv_not_new_high = max(obv_window) > obv_window[-1]
|
||||
obv_bear_div = price_made_new_high and obv_not_new_high
|
||||
|
||||
# VWAP 偏离度
|
||||
vwap_dev = (k.close - vwap) / vwap if vwap > 0 else 0
|
||||
|
||||
# ── 持仓管理 ──
|
||||
if self._side == "long":
|
||||
stop = self._entry_price - self.cfg.atr_stop * ca
|
||||
if k.close < stop or vwap_dev < -0.01: # 回到 VWAP 下方
|
||||
self._side = ""
|
||||
return Signal(symbol=self.cfg.symbol, side="SELL", reason="止损或回VWAP", timestamp=k.open_time)
|
||||
|
||||
elif self._side == "short":
|
||||
stop = self._entry_price + self.cfg.atr_stop * ca
|
||||
if k.close > stop or vwap_dev > 0.01:
|
||||
self._side = ""
|
||||
return Signal(symbol=self.cfg.symbol, side="BUY", reason="止损或回VWAP", timestamp=k.open_time)
|
||||
|
||||
# ── 入场 ──
|
||||
else:
|
||||
if obv_bull_div and vwap_dev < -self.cfg.vwap_std * 0.02: # 价格显著低于 VWAP
|
||||
self._side = "long"
|
||||
self._entry_price = k.close
|
||||
return Signal(symbol=self.cfg.symbol, side="BUY", reason=f"OBV底背离+VWAP下方", timestamp=k.open_time)
|
||||
elif obv_bear_div and vwap_dev > self.cfg.vwap_std * 0.02:
|
||||
self._side = "short"
|
||||
self._entry_price = k.close
|
||||
return Signal(symbol=self.cfg.symbol, side="SELL", reason=f"OBV顶背离+VWAP上方", timestamp=k.open_time)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 执行
|
||||
# ════════════════════════════════════════════════════════
|
||||
|
||||
SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
|
||||
INTERVALS = ["15m", "30m", "1h"]
|
||||
|
||||
STRATEGIES = {
|
||||
"1.均值回归": (MeanRevConfig, MeanRevStrategy),
|
||||
"2.多TF(4h+1h)": (MultiTFConfig, MultiTFStrategy),
|
||||
"3.波动突破": (VolBreakConfig, VolBreakStrategy),
|
||||
"4.成交量": (VolumeConfig, VolumeStrategy),
|
||||
}
|
||||
|
||||
# 均值回归参数
|
||||
MEAN_REV_PARAMS = {
|
||||
"BTCUSDT": {"rsi_period": 14, "rsi_oversold": 25, "rsi_overbought": 75, "bb_period": 20, "bb_std": 2.0, "atr_stop": 1.5},
|
||||
"ETHUSDT": {"rsi_period": 14, "rsi_oversold": 25, "rsi_overbought": 75, "bb_period": 20, "bb_std": 2.0, "atr_stop": 1.5},
|
||||
"BNBUSDT": {"rsi_period": 14, "rsi_oversold": 25, "rsi_overbought": 75, "bb_period": 20, "bb_std": 2.0, "atr_stop": 1.5},
|
||||
"SOLUSDT": {"rsi_period": 14, "rsi_oversold": 25, "rsi_overbought": 75, "bb_period": 20, "bb_std": 2.0, "atr_stop": 1.5},
|
||||
}
|
||||
|
||||
# 多TF参数
|
||||
MULTI_TF_PARAMS = {
|
||||
"BTCUSDT": {"fast": 20, "slow": 100, "atr_stop": 2.0},
|
||||
"ETHUSDT": {"fast": 20, "slow": 100, "atr_stop": 2.0},
|
||||
"BNBUSDT": {"fast": 20, "slow": 100, "atr_stop": 2.0},
|
||||
"SOLUSDT": {"fast": 20, "slow": 100, "atr_stop": 2.0},
|
||||
}
|
||||
|
||||
# 波动突破参数
|
||||
VOL_BREAK_PARAMS = {
|
||||
"BTCUSDT": {"atr_period": 14, "squeeze_period": 20, "squeeze_ratio": 0.7, "atr_stop": 2.0},
|
||||
"ETHUSDT": {"atr_period": 14, "squeeze_period": 20, "squeeze_ratio": 0.7, "atr_stop": 2.0},
|
||||
"BNBUSDT": {"atr_period": 14, "squeeze_period": 20, "squeeze_ratio": 0.7, "atr_stop": 2.0},
|
||||
"SOLUSDT": {"atr_period": 14, "squeeze_period": 20, "squeeze_ratio": 0.7, "atr_stop": 2.0},
|
||||
}
|
||||
|
||||
# 成交量参数
|
||||
VOLUME_PARAMS = {
|
||||
"BTCUSDT": {"obv_lookback": 20, "vwap_std": 2.0, "atr_stop": 2.0},
|
||||
"ETHUSDT": {"obv_lookback": 20, "vwap_std": 2.0, "atr_stop": 2.0},
|
||||
"BNBUSDT": {"obv_lookback": 20, "vwap_std": 2.0, "atr_stop": 2.0},
|
||||
"SOLUSDT": {"obv_lookback": 20, "vwap_std": 2.0, "atr_stop": 2.0},
|
||||
}
|
||||
|
||||
|
||||
async def run_one(engine_factory, strategy_cls, config_cls, params, symbol, interval, start, end):
|
||||
"""运行单组回测"""
|
||||
INITIAL = 10_000.0
|
||||
sc = config_cls(symbol=symbol, **params)
|
||||
bt = BacktestConfig(symbol=symbol, interval=interval, start_time=start, end_time=end, initial_capital=INITIAL)
|
||||
engine = engine_factory(bt)
|
||||
r = await engine.run(strategy_cls, sc)
|
||||
m = r.metrics
|
||||
return m, INITIAL, m.final_equity
|
||||
|
||||
|
||||
async def main():
|
||||
ds = DataService(config.db)
|
||||
await ds.connect()
|
||||
|
||||
# 预加载所有数据范围
|
||||
ranges: dict[str, dict[str, tuple[datetime, datetime]]] = {}
|
||||
for interval in INTERVALS:
|
||||
ranges[interval] = {}
|
||||
for symbol in SYMBOLS:
|
||||
try:
|
||||
s, e = await ds.fetch_symbol_date_range(symbol, interval)
|
||||
ranges[interval][symbol] = (s, e)
|
||||
except Exception:
|
||||
pass
|
||||
await ds.close()
|
||||
|
||||
all_results: list[dict] = []
|
||||
|
||||
print()
|
||||
print("═" * 135)
|
||||
print(" 日内策略探索 — 4思路 × 4币种 × 3周期")
|
||||
print("═" * 135)
|
||||
|
||||
for strategy_name, (config_cls, strategy_cls) in STRATEGIES.items():
|
||||
print(f"\n ■ {strategy_name}")
|
||||
print(f" {'币种':<10} {'周期':<6} {'本金':>7} {'终值':>9} {'总收益%':>8} {'年化%':>8} {'夏普':>7} {'回撤%':>8} {'交易':>6} {'耗时s':>7}")
|
||||
print(" " + "─" * 115)
|
||||
|
||||
for interval in INTERVALS:
|
||||
for symbol in SYMBOLS:
|
||||
if symbol not in ranges.get(interval, {}):
|
||||
continue
|
||||
start, end = ranges[interval][symbol]
|
||||
|
||||
# 选择参数
|
||||
if strategy_name == "1.均值回归":
|
||||
params = MEAN_REV_PARAMS[symbol]
|
||||
elif strategy_name == "2.多TF(4h+1h)":
|
||||
params = MULTI_TF_PARAMS[symbol]
|
||||
elif strategy_name == "3.波动突破":
|
||||
params = VOL_BREAK_PARAMS[symbol]
|
||||
else:
|
||||
params = VOLUME_PARAMS[symbol]
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
m, initial, final_equity = await run_one(
|
||||
lambda bt: LongShortEngine(bt, db_config=config.db),
|
||||
strategy_cls, config_cls, params,
|
||||
symbol, interval, start, end,
|
||||
)
|
||||
elapsed = time.time() - t0
|
||||
except Exception as ex:
|
||||
print(f" {symbol:<10} {interval:<6} {'错误: ' + str(ex)[:40]}")
|
||||
continue
|
||||
|
||||
print(f" {symbol:<10} {interval:<6} {initial:>7.0f} {final_equity:>9.0f} {m.total_return_pct:>7.1f}% {m.annual_return_pct:>7.1f}% {m.sharpe_ratio:>7.2f} {m.max_drawdown_pct:>7.1f}% {m.total_trades:>6} {elapsed:>6.1f}s")
|
||||
|
||||
all_results.append({
|
||||
"strategy": strategy_name, "interval": interval, "symbol": symbol,
|
||||
"return": m.total_return_pct, "annual": m.annual_return_pct,
|
||||
"sharpe": m.sharpe_ratio, "dd": m.max_drawdown_pct,
|
||||
"trades": m.total_trades, "initial": initial, "final": final_equity,
|
||||
})
|
||||
|
||||
# ── 汇总:每种策略的最佳组合 ──
|
||||
print(f"\n\n ■ 各策略最佳组合 (按夏普排名)")
|
||||
print(f" {'策略':<18} {'级别':<6} {'币种':<10} {'本金':>7} {'终值':>9} {'总收益%':>8} {'年化%':>8} {'夏普':>7} {'回撤%':>8} {'交易':>6}")
|
||||
print(" " + "─" * 115)
|
||||
|
||||
# 每种策略取最佳
|
||||
for sn in STRATEGIES:
|
||||
candidates = [r for r in all_results if r["strategy"] == sn]
|
||||
if not candidates:
|
||||
continue
|
||||
best = max(candidates, key=lambda x: x["sharpe"])
|
||||
print(f" {sn:<18} {best['interval']:<6} {best['symbol']:<10} {best['initial']:>7.0f} {best['final']:>9.0f} {best['return']:>7.1f}% {best['annual']:>7.1f}% {best['sharpe']:>7.2f} {best['dd']:>7.1f}% {best['trades']:>6}")
|
||||
|
||||
print("\n═" * 135)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -31,7 +31,7 @@ from engine.common.base import BaseStrategy, Signal, StrategyConfig
|
||||
from engine.common.models import Kline
|
||||
from engine.common.config import config, DBConfig
|
||||
from engine.data import DataService
|
||||
from engine.indicators import ema, atr
|
||||
from engine.indicators.incremental import EmaInc, AtrInc
|
||||
from engine.backtest.models import BacktestConfig, BacktestMetrics, BacktestResult, BacktestTrade
|
||||
|
||||
|
||||
@@ -364,7 +364,7 @@ class LongShortEmaConfig(StrategyConfig):
|
||||
|
||||
|
||||
class LongShortEmaStrategy(BaseStrategy):
|
||||
"""EMA金叉做多、死叉做空,始终在场"""
|
||||
"""EMA金叉做多、死叉做空,始终在场 — 全部指标增量计算"""
|
||||
|
||||
strategy_type = "long_short_ema"
|
||||
|
||||
@@ -374,6 +374,9 @@ class LongShortEmaStrategy(BaseStrategy):
|
||||
self._closes: list[float] = []
|
||||
self._highs: list[float] = []
|
||||
self._lows: list[float] = []
|
||||
self._ema_fast = EmaInc(c.fast)
|
||||
self._ema_slow = EmaInc(c.slow)
|
||||
self._atr = AtrInc(14)
|
||||
self._highest: float = 0.0
|
||||
self._lowest: float = float('inf')
|
||||
self._position_side: str = "" # "long" / "short"
|
||||
@@ -382,15 +385,19 @@ class LongShortEmaStrategy(BaseStrategy):
|
||||
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
|
||||
|
||||
fast = ema(self._closes, self.cfg.fast)
|
||||
slow = ema(self._closes, self.cfg.slow)
|
||||
atr_vals = atr(self._highs, self._lows, self._closes, 14)
|
||||
cur_f, cur_s, cur_atr = fast[-1], slow[-1], atr_vals[-1]
|
||||
prev_f, prev_s = fast[-2], slow[-2]
|
||||
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
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -23,50 +23,55 @@ from engine.common.base import BaseStrategy, Signal, StrategyConfig
|
||||
from engine.common.models import Kline
|
||||
from engine.common.config import config
|
||||
from engine.backtest import BacktestConfig
|
||||
from engine.indicators import ema, atr
|
||||
from engine.indicators.incremental import EmaInc, AtrInc
|
||||
from engine.data import DataService
|
||||
from engine.example.long_short import LongShortEngine
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 3法判定器
|
||||
# 3法判定器(增量 EMA200,O(1) per bar)
|
||||
# ════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class RegimeDetector3:
|
||||
"""牛熊判定器,内部维护增量 EMA(200),避免每次从头重算"""
|
||||
|
||||
def __init__(self):
|
||||
self._ath = 0.0
|
||||
self._e200 = EmaInc(200)
|
||||
|
||||
def update_ath(self, price: float):
|
||||
def update(self, price: float):
|
||||
"""每根 bar 调一次:更新 ATH + EMA(200)"""
|
||||
if price > self._ath:
|
||||
self._ath = price
|
||||
self._e200.update(price)
|
||||
|
||||
def ema200_slope(self, closes: list[float], idx: int) -> str:
|
||||
if idx < 210: return "unknown"
|
||||
e200 = ema(closes, 200)
|
||||
def _ema200_slope(self, idx: int) -> str:
|
||||
if idx < 220: return "unknown"
|
||||
e200 = self._e200
|
||||
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: list[float], idx: int) -> str:
|
||||
def _price_vs_ema200(self, price: float, idx: int) -> str:
|
||||
if idx < 210: return "unknown"
|
||||
e200 = ema(closes, 200)
|
||||
if e200[idx] == 0: return "unknown"
|
||||
return "bull" if closes[idx] > e200[idx] else "bear"
|
||||
e = self._e200[idx]
|
||||
if e == 0: return "unknown"
|
||||
return "bull" if price > e else "bear"
|
||||
|
||||
def ath_drawdown(self, closes: list[float], idx: int) -> str:
|
||||
def _ath_drawdown(self, price: float) -> str:
|
||||
if self._ath == 0: return "unknown"
|
||||
dd = (closes[idx] - self._ath) / self._ath
|
||||
dd = (price - self._ath) / self._ath
|
||||
if dd > -0.15: return "bull"
|
||||
if dd < -0.35: return "bear"
|
||||
return "sideways"
|
||||
|
||||
def detect(self, closes: list[float], idx: int) -> str:
|
||||
r1 = self.ema200_slope(closes, idx)
|
||||
r2 = self.price_vs_ema200(closes, idx)
|
||||
r3 = self.ath_drawdown(closes, idx)
|
||||
def detect(self, price: float, idx: int) -> str:
|
||||
r1 = self._ema200_slope(idx)
|
||||
r2 = self._price_vs_ema200(price, idx)
|
||||
r3 = self._ath_drawdown(price)
|
||||
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"
|
||||
@@ -84,7 +89,7 @@ class RegimeEmaConfig(StrategyConfig):
|
||||
|
||||
|
||||
class RegimeEmaStrategy(BaseStrategy):
|
||||
"""按市场状态自适应做多/做空"""
|
||||
"""按市场状态自适应做多/做空 — 全部指标增量计算,O(1) per bar"""
|
||||
|
||||
strategy_type = "regime_ema"
|
||||
|
||||
@@ -93,19 +98,28 @@ class RegimeEmaStrategy(BaseStrategy):
|
||||
self.cfg = c
|
||||
self._c: list[float] = []; self._h: list[float] = []; self._l: list[float] = []
|
||||
self._detector = RegimeDetector3()
|
||||
self._ema_fast = EmaInc(c.fast)
|
||||
self._ema_slow = EmaInc(c.slow)
|
||||
self._atr = AtrInc(14)
|
||||
self._side: str = ""; self._hp: float = 0.0; self._lp: float = 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)
|
||||
|
||||
# 增量更新所有指标(O(1) each)
|
||||
self._detector.update(k.close)
|
||||
self._ema_fast.update(k.close)
|
||||
self._ema_slow.update(k.close)
|
||||
self._atr.update(k.high, k.low, k.close)
|
||||
|
||||
n = len(self._c)
|
||||
if n < 220: return None
|
||||
|
||||
regime = self._detector.detect(self._c, n - 1)
|
||||
regime = self._detector.detect(k.close, 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]
|
||||
cf, cs = self._ema_fast[-1], self._ema_slow[-1]
|
||||
ca = self._atr[-1]
|
||||
pf, ps = self._ema_fast[-2], self._ema_slow[-2]
|
||||
if cf == 0 or cs == 0 or ca == 0: return None
|
||||
golden = pf <= ps and cf > cs; death = pf >= ps and cf < cs
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
牛熊自适应策略 — 日内级别全币种扫描 (15m / 30m / 1h)
|
||||
|
||||
用法:
|
||||
source .venv/bin/activate && python example/regime_intraday.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
_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.config import config
|
||||
from engine.backtest import BacktestConfig
|
||||
from engine.data import DataService
|
||||
from engine.example.regime_all import RegimeEmaStrategy, RegimeEmaConfig
|
||||
from engine.example.long_short import LongShortEngine
|
||||
|
||||
SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
|
||||
INTERVALS = ["15m", "30m", "1h"]
|
||||
|
||||
# 沿用 4h 级别优化参数(日内级别可能需单独调参)
|
||||
PARAMS = {
|
||||
"BTCUSDT": (10, 50),
|
||||
"ETHUSDT": (10, 75),
|
||||
"BNBUSDT": (20, 50),
|
||||
"SOLUSDT": (30, 50),
|
||||
}
|
||||
|
||||
|
||||
async def get_actual_range(ds: DataService, symbol: str, interval: str):
|
||||
"""获取币种指定周期的实际数据范围"""
|
||||
start, end = await ds.fetch_symbol_date_range(symbol, interval)
|
||||
return start, end
|
||||
|
||||
|
||||
async def main():
|
||||
ds = DataService(config.db)
|
||||
await ds.connect()
|
||||
|
||||
print()
|
||||
print("═" * 130)
|
||||
print(" 牛熊自适应策略 — 日内级别全币种扫描 | 牛市只多/熊市只空/震荡空仓")
|
||||
print("═" * 130)
|
||||
|
||||
total_start = time.time()
|
||||
results: list[dict] = []
|
||||
|
||||
for interval in INTERVALS:
|
||||
print(f"\n ■ {interval} 级别")
|
||||
print(f" {'币种':<10} {'数据范围':<22} {'总收益%':>8} {'年化%':>8} {'夏普':>7} {'回撤%':>8} {'交易':>6} {'多头P&L':>11} {'空头P&L':>11} {'耗时s':>7}")
|
||||
print(" " + "─" * 125)
|
||||
|
||||
for symbol in SYMBOLS:
|
||||
fast, slow = PARAMS[symbol]
|
||||
sc = RegimeEmaConfig(symbol=symbol, fast=fast, slow=slow)
|
||||
|
||||
try:
|
||||
act_start, act_end = await get_actual_range(ds, symbol, interval)
|
||||
range_str = f"{act_start.date()}~{act_end.date()}"
|
||||
except Exception:
|
||||
# 数据不存在,跳过
|
||||
print(f" {symbol:<10} {'无数据':<22}")
|
||||
continue
|
||||
|
||||
bt = BacktestConfig(
|
||||
symbol=symbol, interval=interval,
|
||||
start_time=act_start, end_time=act_end,
|
||||
initial_capital=10_000.0,
|
||||
)
|
||||
engine = LongShortEngine(bt, db_config=config.db)
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
r = await engine.run(RegimeEmaStrategy, sc)
|
||||
elapsed = time.time() - t0
|
||||
except Exception as ex:
|
||||
print(f" {symbol:<10} {range_str:<22} {'错误: ' + str(ex)[:30]}")
|
||||
continue
|
||||
|
||||
m = r.metrics
|
||||
long_t = [t for t in r.trades if t.pnl is not None and t.side == "SELL"]
|
||||
short_t = [t for t in r.trades if t.pnl is not None and t.side == "BUY"]
|
||||
long_pnl = sum(t.pnl for t in long_t) if long_t else 0
|
||||
short_pnl = sum(t.pnl for t in short_t) if short_t else 0
|
||||
|
||||
print(f" {symbol:<10} {range_str:<22} {m.total_return_pct:>7.1f}% {m.annual_return_pct:>7.1f}% {m.sharpe_ratio:>7.2f} {m.max_drawdown_pct:>7.1f}% {m.total_trades:>6} {long_pnl:>+10.0f} {short_pnl:>+10.0f} {elapsed:>6.1f}s")
|
||||
|
||||
results.append({
|
||||
"interval": interval, "symbol": symbol,
|
||||
"return": m.total_return_pct, "annual": m.annual_return_pct,
|
||||
"sharpe": m.sharpe_ratio, "dd": m.max_drawdown_pct,
|
||||
"trades": m.total_trades, "win_rate": m.win_rate,
|
||||
"profit_factor": m.profit_factor,
|
||||
"long_pnl": long_pnl, "short_pnl": short_pnl,
|
||||
"elapsed": elapsed,
|
||||
})
|
||||
|
||||
await ds.close()
|
||||
|
||||
# ── 汇总排名 ──
|
||||
total_elapsed = time.time() - total_start
|
||||
print(f"\n ■ 最佳组合 (按夏普排名)")
|
||||
print(f" {'排名':<5} {'级别':<6} {'币种':<10} {'总收益%':>8} {'夏普':>7} {'回撤%':>8} {'交易':>6} {'胜率%':>7} {'盈亏比':>7}")
|
||||
print(" " + "─" * 75)
|
||||
|
||||
sorted_results = sorted(results, key=lambda x: x["sharpe"], reverse=True)
|
||||
for i, r in enumerate(sorted_results):
|
||||
medal = ["🥇", "🥈", "🥉"][i] if i < 3 else f"{i+1:>2}."
|
||||
print(f" {medal:<5} {r['interval']:<6} {r['symbol']:<10} {r['return']:>7.1f}% {r['sharpe']:>7.2f} {r['dd']:>7.1f}% {r['trades']:>6} {r['win_rate']*100:>6.1f}% {r['profit_factor']:>7.2f}")
|
||||
|
||||
print(f"\n 总耗时: {total_elapsed:.1f}s")
|
||||
print("═" * 130)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,6 +1,6 @@
|
||||
"""
|
||||
牛熊自适应策略 — 多时间级别回测对比
|
||||
4h / 1d × 全量数据 / 近两年 (2024.06-2026.06)
|
||||
2h / 4h / 6h / 1d × 全量数据 / 近两年 (2024.06-2026.06)
|
||||
|
||||
用法:
|
||||
source .venv/bin/activate && python example/regime_timeframe_comparison.py
|
||||
@@ -37,7 +37,7 @@ PARAMS = {
|
||||
"SOLUSDT": (30, 50),
|
||||
}
|
||||
|
||||
INTERVALS = ["4h", "1d"]
|
||||
INTERVALS = ["2h", "4h", "6h", "1d"]
|
||||
|
||||
# 近两年:2024年6月 → 2026年6月
|
||||
YEAR_START = datetime(2024, 6, 1)
|
||||
@@ -267,7 +267,7 @@ async def main():
|
||||
|
||||
# 写出文件
|
||||
out_path = (
|
||||
Path(__file__).resolve().parent.parent / "backtest" / "TIMEFRAME_COMPARISON_2Y.md"
|
||||
Path(__file__).resolve().parent.parent / "backtest" / "TIMEFRAME_COMPARISON_2H_6H.md"
|
||||
)
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(out) + "\n")
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""获取近一年Top3策略的详细交易记录"""
|
||||
import asyncio, sys, json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
_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.config import config
|
||||
from engine.backtest.models import BacktestConfig
|
||||
from engine.example.long_short import LongShortEngine
|
||||
from engine.example.full_comparison import (
|
||||
VolBreakStrategy, VolBreakConfig,
|
||||
EmaCrossStrategy, EmaCrossConfig,
|
||||
)
|
||||
from engine.common.base import Signal
|
||||
|
||||
NOW = datetime.now(timezone.utc)
|
||||
ONE_YEAR_AGO = NOW - timedelta(days=365)
|
||||
INITIAL = 10_000.0
|
||||
|
||||
TASKS = [
|
||||
("ATR波动率突破 ETHUSDT 4h", VolBreakConfig, VolBreakStrategy,
|
||||
"ETHUSDT", "4h", lambda s: VolBreakConfig(symbol=s, atr_period=14, squeeze_period=20, squeeze_ratio=0.7, atr_stop=2.0)),
|
||||
("EMA双均线多空 ETHUSDT 1d", EmaCrossConfig, EmaCrossStrategy,
|
||||
"ETHUSDT", "1d", lambda s: EmaCrossConfig(symbol=s, fast=10, slow=50, atr_stop=2.5)),
|
||||
("EMA双均线多空 BTCUSDT 1d", EmaCrossConfig, EmaCrossStrategy,
|
||||
"BTCUSDT", "1d", lambda s: EmaCrossConfig(symbol=s, fast=10, slow=50, atr_stop=2.5)),
|
||||
]
|
||||
|
||||
|
||||
def fmt_ts(ts_ms):
|
||||
return datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
async def run_one(label, config_cls, strategy_cls, symbol, interval, mkcfg):
|
||||
sc = mkcfg(symbol)
|
||||
bt = BacktestConfig(symbol=symbol, interval=interval,
|
||||
start_time=ONE_YEAR_AGO, end_time=NOW,
|
||||
initial_capital=INITIAL, warmup_bars=150)
|
||||
engine = LongShortEngine(bt, db_config=config.db)
|
||||
r = await engine.run(strategy_cls, sc)
|
||||
return label, r
|
||||
|
||||
|
||||
async def main():
|
||||
results = []
|
||||
for task in TASKS:
|
||||
label, r = await run_one(*task)
|
||||
results.append((label, r))
|
||||
|
||||
for label, r in results:
|
||||
m = r.metrics
|
||||
trades = r.trades
|
||||
|
||||
# 配对交易
|
||||
paired = []
|
||||
pending = None
|
||||
for t in trades:
|
||||
if t.side == "BUY" and t.pnl is None:
|
||||
pending = {"entry_ts": t.timestamp, "entry_price": t.price, "entry_reason": t.reason}
|
||||
elif t.side == "SELL" and pending and t.pnl is not None:
|
||||
paired.append({**pending, "exit_ts": t.timestamp, "exit_price": t.price,
|
||||
"exit_reason": t.reason, "pnl": t.pnl})
|
||||
pending = None
|
||||
elif t.side == "SELL" and t.pnl is None:
|
||||
pending = {"entry_ts": t.timestamp, "entry_price": t.price, "entry_reason": t.reason, "short": True}
|
||||
elif t.side == "BUY" and pending and t.pnl is not None and pending.get("short"):
|
||||
paired.append({**pending, "exit_ts": t.timestamp, "exit_price": t.price,
|
||||
"exit_reason": t.reason, "pnl": t.pnl})
|
||||
pending = None
|
||||
|
||||
cfg = r.config
|
||||
print(f"\n═══ {label} ═══")
|
||||
print(f" 本金 {INITIAL:,.0f} → 终值 {m.final_equity:,.0f} | {m.total_return_pct:+.1f}% | 年化 {m.annual_return_pct:+.1f}%")
|
||||
print(f" 夏普 {m.sharpe_ratio:.2f} | 回撤 {m.max_drawdown_pct:.1f}% | 胜率 {m.win_rate*100:.1f}% | 盈亏比 {m.profit_factor:.2f} | {m.total_trades}笔")
|
||||
print(f" 日期 {cfg.start_time.date()} ~ {cfg.end_time.date()}")
|
||||
print()
|
||||
print(f" {'#':>3} {'入场时间':<19} {'入场价':>10} {'入场原因':<25} {'出场时间':<19} {'出场价':>10} {'出场原因':<25} {'盈亏':>10}")
|
||||
print(" " + "─" * 130)
|
||||
|
||||
total_pnl = 0
|
||||
for i, p in enumerate(paired):
|
||||
total_pnl += p["pnl"]
|
||||
side = "做空" if p.get("short") else "做多"
|
||||
print(f" {i+1:>3} {fmt_ts(p['entry_ts']):<19} {p['entry_price']:>10.4f} {p['entry_reason']:<25} {fmt_ts(p['exit_ts']):<19} {p['exit_price']:>10.4f} {p['exit_reason']:<25} {p['pnl']:>+10.2f}")
|
||||
|
||||
print(" " + "─" * 130)
|
||||
wins = sum(1 for p in paired if p["pnl"] > 0)
|
||||
print(f" 合计 {len(paired)} 笔 | 盈利 {wins} 笔 | 总盈亏 {total_pnl:+,.2f} USDT")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
策略3 ATR波动率突破 — 1h 全量 vs 近两年对比 + 近两年详细订单
|
||||
|
||||
用法:
|
||||
source .venv/bin/activate && python example/vol_break_compare.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
_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.config import config
|
||||
from engine.backtest import BacktestConfig
|
||||
from engine.data import DataService
|
||||
from engine.example.long_short import LongShortEngine
|
||||
from engine.example.intraday_explore import VolBreakStrategy, VolBreakConfig
|
||||
|
||||
SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
|
||||
INTERVAL = "1h"
|
||||
INITIAL = 10_000.0
|
||||
|
||||
PARAMS = {
|
||||
"BTCUSDT": {"atr_period": 14, "squeeze_period": 20, "squeeze_ratio": 0.7, "atr_stop": 2.0},
|
||||
"ETHUSDT": {"atr_period": 14, "squeeze_period": 20, "squeeze_ratio": 0.7, "atr_stop": 2.0},
|
||||
"BNBUSDT": {"atr_period": 14, "squeeze_period": 20, "squeeze_ratio": 0.7, "atr_stop": 2.0},
|
||||
"SOLUSDT": {"atr_period": 14, "squeeze_period": 20, "squeeze_ratio": 0.7, "atr_stop": 2.0},
|
||||
}
|
||||
|
||||
# 近两年截止日期
|
||||
RECENT_END = datetime(2025, 6, 1, tzinfo=timezone.utc)
|
||||
RECENT_START = RECENT_END - timedelta(days=365 * 2) # 2023-06
|
||||
|
||||
|
||||
def fmt_ts(ts_ms: float) -> str:
|
||||
dt = datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc)
|
||||
return dt.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def pair_trades(trades: list) -> list[dict]:
|
||||
"""将 BUY/SELL 配对为完整交易"""
|
||||
paired = []
|
||||
pending_open = None # (side, price, reason, ts)
|
||||
|
||||
for t in trades:
|
||||
if t.side == "BUY":
|
||||
if pending_open and pending_open["side"] == "short":
|
||||
# 平空仓 (BUY to close short)
|
||||
paired.append({
|
||||
"type": "做空",
|
||||
"entry_ts": pending_open["ts"],
|
||||
"entry_price": pending_open["price"],
|
||||
"entry_reason": pending_open["reason"],
|
||||
"exit_ts": t.timestamp,
|
||||
"exit_price": t.price,
|
||||
"exit_reason": t.reason,
|
||||
"pnl": t.pnl or 0,
|
||||
})
|
||||
pending_open = None
|
||||
elif t.pnl is None:
|
||||
# 开多仓
|
||||
pending_open = {"side": "long", "price": t.price, "reason": t.reason, "ts": t.timestamp}
|
||||
|
||||
elif t.side == "SELL":
|
||||
if pending_open and pending_open["side"] == "long":
|
||||
# 平多仓 (SELL to close long)
|
||||
paired.append({
|
||||
"type": "做多",
|
||||
"entry_ts": pending_open["ts"],
|
||||
"entry_price": pending_open["price"],
|
||||
"entry_reason": pending_open["reason"],
|
||||
"exit_ts": t.timestamp,
|
||||
"exit_price": t.price,
|
||||
"exit_reason": t.reason,
|
||||
"pnl": t.pnl or 0,
|
||||
})
|
||||
pending_open = None
|
||||
elif t.pnl is None:
|
||||
# 开空仓
|
||||
pending_open = {"side": "short", "price": t.price, "reason": t.reason, "ts": t.timestamp}
|
||||
|
||||
return paired
|
||||
|
||||
|
||||
async def run_backtest(symbol, start, end):
|
||||
sc = VolBreakConfig(symbol=symbol, **PARAMS[symbol])
|
||||
bt = BacktestConfig(symbol=symbol, interval=INTERVAL, start_time=start, end_time=end, initial_capital=INITIAL)
|
||||
engine = LongShortEngine(bt, db_config=config.db)
|
||||
r = await engine.run(VolBreakStrategy, sc)
|
||||
return r
|
||||
|
||||
|
||||
async def main():
|
||||
ds = DataService(config.db)
|
||||
await ds.connect()
|
||||
|
||||
# 获取数据范围
|
||||
full_ranges = {}
|
||||
for symbol in SYMBOLS:
|
||||
try:
|
||||
s, e = await ds.fetch_symbol_date_range(symbol, INTERVAL)
|
||||
full_ranges[symbol] = (s, e)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── 运行全量 + 近两年 ──
|
||||
full_results = {}
|
||||
recent_results = {}
|
||||
for symbol in SYMBOLS:
|
||||
if symbol not in full_ranges:
|
||||
continue
|
||||
fs, fe = full_ranges[symbol]
|
||||
|
||||
print(f" 运行 {symbol} 全量 ({fs.date()}~{fe.date()})...", end=" ", flush=True)
|
||||
t0 = time.time()
|
||||
full_results[symbol] = await run_backtest(symbol, fs, fe)
|
||||
print(f"{time.time()-t0:.1f}s")
|
||||
|
||||
rs = max(RECENT_START, fs)
|
||||
re = min(RECENT_END, fe)
|
||||
print(f" 运行 {symbol} 近两年 ({rs.date()}~{re.date()})...", end=" ", flush=True)
|
||||
t0 = time.time()
|
||||
recent_results[symbol] = await run_backtest(symbol, rs, re)
|
||||
print(f"{time.time()-t0:.1f}s")
|
||||
|
||||
await ds.close()
|
||||
|
||||
# ── 全量 vs 近两年 对比表 ──
|
||||
print()
|
||||
print("═" * 145)
|
||||
print(" ATR波动率突破 1h — 全量 vs 近两年 (2023.06~2025.06)")
|
||||
print("═" * 145)
|
||||
print()
|
||||
header = f" {'币种':<10} | {'—————— 全量数据 ——————':>55} | {'—————— 近两年 ——————':>55}"
|
||||
print(header)
|
||||
sub = f" {'':<10} | {'本金':>6} {'终值':>8} {'总收益%':>8} {'年化%':>7} {'夏普':>6} {'回撤%':>7} {'交易':>5} | {'本金':>6} {'终值':>8} {'总收益%':>8} {'年化%':>7} {'夏普':>6} {'回撤%':>7} {'交易':>5}"
|
||||
print(sub)
|
||||
print(" " + "─" * 143)
|
||||
|
||||
for symbol in SYMBOLS:
|
||||
if symbol not in full_results:
|
||||
continue
|
||||
f = full_results[symbol].metrics
|
||||
r = recent_results[symbol].metrics
|
||||
print(f" {symbol:<10} | {INITIAL:>6.0f} {f.final_equity:>8.0f} {f.total_return_pct:>7.1f}% {f.annual_return_pct:>6.1f}% {f.sharpe_ratio:>6.2f} {f.max_drawdown_pct:>6.1f}% {f.total_trades:>5} | {INITIAL:>6.0f} {r.final_equity:>8.0f} {r.total_return_pct:>7.1f}% {r.annual_return_pct:>6.1f}% {r.sharpe_ratio:>6.2f} {r.max_drawdown_pct:>6.1f}% {r.total_trades:>5}")
|
||||
|
||||
print("\n═" * 145)
|
||||
|
||||
# ── 近两年详细订单 ──
|
||||
for symbol in SYMBOLS:
|
||||
if symbol not in recent_results:
|
||||
continue
|
||||
result = recent_results[symbol]
|
||||
m = result.metrics
|
||||
rng = recent_results[symbol].config
|
||||
paired = pair_trades(result.trades)
|
||||
|
||||
print(f"\n{'─' * 145}")
|
||||
print(f" {symbol} 近两年 ({rng.start_time.date()}~{rng.end_time.date()}) — {len(paired)} 笔完整交易")
|
||||
print(f" 本金 {INITIAL:,.0f} → 终值 {m.final_equity:,.0f} | 总收益 {m.total_return_pct:.1f}% | 年化 {m.annual_return_pct:.1f}% | 夏普 {m.sharpe_ratio:.2f} | 回撤 {m.max_drawdown_pct:.1f}%")
|
||||
print(f" 胜率 {m.win_rate*100:.1f}% | 盈亏比 {m.profit_factor:.2f} | 最佳单笔 {m.best_trade_pnl:+,.0f} | 最差 {m.worst_trade_pnl:+,.0f} | 平均 {m.avg_trade_pnl:+,.0f}")
|
||||
print(f"{'─' * 145}")
|
||||
print(f" {'#':>3} {'类型':<5} {'入场时间':<19} {'入场价':>10} {'出场时间':<19} {'出场价':>10} {'盈亏':>12} {'入场原因':<30} {'出场原因':<30}")
|
||||
print(f" {'─' * 141}")
|
||||
|
||||
total_pnl = 0
|
||||
wins = 0
|
||||
for i, p in enumerate(paired):
|
||||
pnl = p["pnl"]
|
||||
total_pnl += pnl
|
||||
if pnl > 0:
|
||||
wins += 1
|
||||
pnl_str = f"{pnl:+,.0f}"
|
||||
print(f" {i+1:>3} {p['type']:<5} {p['entry_ts']:<19} {p['entry_price']:>10.4f} {p['exit_ts']:<19} {p['exit_price']:>10.4f} {pnl_str:>12} {p['entry_reason']:<30} {p['exit_reason']:<30}")
|
||||
|
||||
print(f" {'─' * 141}")
|
||||
print(f" 合计: {len(paired)} 笔 | 盈利 {wins} 笔 ({wins/len(paired)*100 if paired else 0:.0f}%) | 总盈亏 {total_pnl:+,.0f} USDT")
|
||||
|
||||
print(f"\n{'─' * 145}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
策略3 ATR波动率突破 — 全量 / 近两年 / 近一年 / 近半年 对比
|
||||
|
||||
用法:
|
||||
source .venv/bin/activate && python example/vol_break_periods.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
_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.config import config
|
||||
from engine.backtest import BacktestConfig
|
||||
from engine.data import DataService
|
||||
from engine.example.long_short import LongShortEngine
|
||||
from engine.example.intraday_explore import VolBreakStrategy, VolBreakConfig
|
||||
|
||||
SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
|
||||
INTERVAL = "1h"
|
||||
INITIAL = 10_000.0
|
||||
|
||||
TODAY = datetime.now(timezone.utc)
|
||||
|
||||
# 时间段定义
|
||||
PERIODS = {
|
||||
"全量": (None, TODAY), # start 动态获取
|
||||
"近两年": (TODAY - timedelta(days=365 * 2), TODAY),
|
||||
"近一年": (TODAY - timedelta(days=365), TODAY),
|
||||
"近半年": (TODAY - timedelta(days=182), TODAY),
|
||||
}
|
||||
|
||||
PARAMS = {
|
||||
"BTCUSDT": {"atr_period": 14, "squeeze_period": 20, "squeeze_ratio": 0.7, "atr_stop": 2.0},
|
||||
"ETHUSDT": {"atr_period": 14, "squeeze_period": 20, "squeeze_ratio": 0.7, "atr_stop": 2.0},
|
||||
"BNBUSDT": {"atr_period": 14, "squeeze_period": 20, "squeeze_ratio": 0.7, "atr_stop": 2.0},
|
||||
"SOLUSDT": {"atr_period": 14, "squeeze_period": 20, "squeeze_ratio": 0.7, "atr_stop": 2.0},
|
||||
}
|
||||
|
||||
|
||||
def fmt_ts(ts_ms: float) -> str:
|
||||
dt = datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc)
|
||||
return dt.strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def pair_trades(trades: list) -> list[dict]:
|
||||
paired = []
|
||||
pending_open = None
|
||||
|
||||
for t in trades:
|
||||
if t.side == "BUY":
|
||||
if pending_open and pending_open["side"] == "short":
|
||||
paired.append({
|
||||
"type": "做空",
|
||||
"entry_ts": pending_open["ts"],
|
||||
"entry_price": pending_open["price"],
|
||||
"entry_reason": pending_open["reason"],
|
||||
"exit_ts": t.timestamp,
|
||||
"exit_price": t.price,
|
||||
"exit_reason": t.reason,
|
||||
"pnl": t.pnl or 0,
|
||||
})
|
||||
pending_open = None
|
||||
elif t.pnl is None:
|
||||
pending_open = {"side": "long", "price": t.price, "reason": t.reason, "ts": t.timestamp}
|
||||
|
||||
elif t.side == "SELL":
|
||||
if pending_open and pending_open["side"] == "long":
|
||||
paired.append({
|
||||
"type": "做多",
|
||||
"entry_ts": pending_open["ts"],
|
||||
"entry_price": pending_open["price"],
|
||||
"entry_reason": pending_open["reason"],
|
||||
"exit_ts": t.timestamp,
|
||||
"exit_price": t.price,
|
||||
"exit_reason": t.reason,
|
||||
"pnl": t.pnl or 0,
|
||||
})
|
||||
pending_open = None
|
||||
elif t.pnl is None:
|
||||
pending_open = {"side": "short", "price": t.price, "reason": t.reason, "ts": t.timestamp}
|
||||
|
||||
return paired
|
||||
|
||||
|
||||
async def run_backtest(symbol, start, end):
|
||||
sc = VolBreakConfig(symbol=symbol, **PARAMS[symbol])
|
||||
bt = BacktestConfig(symbol=symbol, interval=INTERVAL, start_time=start, end_time=end, initial_capital=INITIAL)
|
||||
engine = LongShortEngine(bt, db_config=config.db)
|
||||
r = await engine.run(VolBreakStrategy, sc)
|
||||
return r
|
||||
|
||||
|
||||
async def main():
|
||||
ds = DataService(config.db)
|
||||
await ds.connect()
|
||||
|
||||
full_ranges = {}
|
||||
for symbol in SYMBOLS:
|
||||
try:
|
||||
s, e = await ds.fetch_symbol_date_range(symbol, INTERVAL)
|
||||
full_ranges[symbol] = (s, e)
|
||||
print(f" {symbol}: {s.date()} ~ {e.date()}")
|
||||
except Exception as ex:
|
||||
print(f" {symbol}: 获取范围失败 {ex}")
|
||||
|
||||
# 运行所有时间段
|
||||
all_results: dict[str, dict[str, object]] = {} # symbol -> period_name -> result
|
||||
|
||||
for symbol in SYMBOLS:
|
||||
if symbol not in full_ranges:
|
||||
continue
|
||||
all_results[symbol] = {}
|
||||
fs, fe = full_ranges[symbol]
|
||||
|
||||
for period_name, (ps, pe) in PERIODS.items():
|
||||
start = fs if period_name == "全量" else max(ps, fs)
|
||||
end = min(pe, fe)
|
||||
|
||||
if start >= end:
|
||||
print(f" {symbol} {period_name}: 无有效数据范围,跳过")
|
||||
continue
|
||||
|
||||
print(f" 运行 {symbol} {period_name} ({start.date()}~{end.date()})...", end=" ", flush=True)
|
||||
t0 = time.time()
|
||||
try:
|
||||
all_results[symbol][period_name] = await run_backtest(symbol, start, end)
|
||||
print(f"{time.time()-t0:.1f}s")
|
||||
except Exception as ex:
|
||||
print(f"错误: {ex}")
|
||||
|
||||
await ds.close()
|
||||
|
||||
# ════════════════════════════════════════════════════
|
||||
# 对比表
|
||||
# ════════════════════════════════════════════════════
|
||||
print()
|
||||
print("═" * 165)
|
||||
print(f" ATR波动率突破 1h — 全量 / 近两年 / 近一年 / 近半年 对比 ({TODAY.strftime('%Y-%m-%d')} 截止)")
|
||||
print("═" * 165)
|
||||
print()
|
||||
|
||||
col_w = 36
|
||||
for symbol in SYMBOLS:
|
||||
if symbol not in all_results:
|
||||
continue
|
||||
print(f" ■ {symbol}")
|
||||
print(f" {'时段':<8} {'本金':>7} {'终值':>9} {'总收益%':>8} {'年化%':>8} {'夏普':>7} {'回撤%':>8} {'胜率%':>7} {'盈亏比':>7} {'交易':>5} {'数据范围'}")
|
||||
print(" " + "─" * 163)
|
||||
|
||||
for period_name in PERIODS:
|
||||
if period_name not in all_results[symbol]:
|
||||
continue
|
||||
r = all_results[symbol][period_name]
|
||||
m = r.metrics
|
||||
data_range = f"{r.config.start_time.date()}~{r.config.end_time.date()}"
|
||||
print(f" {period_name:<8} {INITIAL:>7.0f} {m.final_equity:>9.0f} {m.total_return_pct:>7.1f}% {m.annual_return_pct:>7.1f}% {m.sharpe_ratio:>7.2f} {m.max_drawdown_pct:>7.1f}% {m.win_rate*100:>6.1f}% {m.profit_factor:>7.2f} {m.total_trades:>5} {data_range}")
|
||||
print()
|
||||
|
||||
# ════════════════════════════════════════════════════
|
||||
# 详细订单
|
||||
# ════════════════════════════════════════════════════
|
||||
print("═" * 165)
|
||||
print(" 各时段详细订单")
|
||||
print("═" * 165)
|
||||
|
||||
for symbol in SYMBOLS:
|
||||
if symbol not in all_results:
|
||||
continue
|
||||
for period_name in PERIODS:
|
||||
if period_name not in all_results[symbol]:
|
||||
continue
|
||||
result = all_results[symbol][period_name]
|
||||
m = result.metrics
|
||||
paired = pair_trades(result.trades)
|
||||
|
||||
if len(paired) == 0:
|
||||
continue
|
||||
|
||||
print(f"\n{'─' * 155}")
|
||||
print(f" {symbol} {period_name} — {len(paired)} 笔完整交易")
|
||||
print(f" 本金 {INITIAL:,.0f} → 终值 {m.final_equity:,.0f} | 总收益 {m.total_return_pct:.1f}% | 年化 {m.annual_return_pct:.1f}% | 夏普 {m.sharpe_ratio:.2f} | 回撤 {m.max_drawdown_pct:.1f}%")
|
||||
print(f" 胜率 {m.win_rate*100:.1f}% | 盈亏比 {m.profit_factor:.2f} | 最佳 {m.best_trade_pnl:+,.0f} | 最差 {m.worst_trade_pnl:+,.0f} | 平均 {m.avg_trade_pnl:+,.0f}")
|
||||
print(f" 数据范围: {result.config.start_time.date()} ~ {result.config.end_time.date()}")
|
||||
print(f"{'─' * 155}")
|
||||
print(f" {'#':>3} {'类型':<5} {'入场时间':<19} {'入场价':>10} {'出场时间':<19} {'出场价':>10} {'盈亏':>12} {'入场原因':<35} {'出场原因':<30}")
|
||||
print(f" {'─' * 153}")
|
||||
|
||||
total_pnl = 0
|
||||
wins = 0
|
||||
for i, p in enumerate(paired):
|
||||
pnl = p["pnl"]
|
||||
total_pnl += pnl
|
||||
if pnl > 0:
|
||||
wins += 1
|
||||
pnl_str = f"{pnl:+,.0f}"
|
||||
print(f" {i+1:>3} {p['type']:<5} {p['entry_ts']:<19} {p['entry_price']:>10.4f} {p['exit_ts']:<19} {p['exit_price']:>10.4f} {pnl_str:>12} {p['entry_reason']:<35} {p['exit_reason']:<30}")
|
||||
|
||||
print(f" {'─' * 153}")
|
||||
print(f" 合计: {len(paired)} 笔 | 盈利 {wins} 笔 ({wins/len(paired)*100 if paired else 0:.0f}%) | 总盈亏 {total_pnl:+,.0f} USDT")
|
||||
|
||||
print(f"\n{'─' * 155}")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,701 @@
|
||||
"""
|
||||
网络验证策略探索 — 5 个业界知名策略 × 全币种 × 1h
|
||||
|
||||
1. 海龟交易 (Turtle) — Donchian 20/10 通道突破 + 2N ATR 止损
|
||||
2. 超级趋势 (SuperTrend) — ATR(10)×3 动态跟踪止损
|
||||
3. MACD金叉死叉 — MACD(12,26,9) 零轴交叉 + ATR 止损
|
||||
4. 布林收缩爆发 (BBSqueeze) — BB 收缩至 KC 内部后扩张突破
|
||||
5. 三均线排列 (TripleEMA) — EMA(10,30,60) 多头/空头排列 + ATR 追踪
|
||||
|
||||
用法:
|
||||
source .venv/bin/activate && python example/web_strategies.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
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 BacktestConfig
|
||||
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"]
|
||||
INTERVAL = "1h"
|
||||
INITIAL = 10_000.0
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 策略 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):
|
||||
"""海龟交易 — Donchian 通道突破 + ATR 动态止损"""
|
||||
|
||||
strategy_type = "turtle"
|
||||
|
||||
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_entry: float = 0.0
|
||||
self._lowest_since_entry: 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
|
||||
|
||||
# Donchian 通道应排除当前 bar(用前 N 根 bar 计算)
|
||||
donchian_high = max(self._highs[-(self.cfg.entry_period + 1):-1])
|
||||
donchian_low = min(self._lows[-(self.cfg.entry_period + 1):-1])
|
||||
donchian_exit_high = max(self._highs[-(self.cfg.exit_period + 1):-1])
|
||||
donchian_exit_low = min(self._lows[-(self.cfg.exit_period + 1):-1])
|
||||
|
||||
# ── 持仓管理 ──
|
||||
if self._side == "long":
|
||||
self._highest_since_entry = max(self._highest_since_entry, k.high)
|
||||
stop = self._entry_price - self.cfg.atr_stop * ca
|
||||
trail_stop = self._highest_since_entry - self.cfg.atr_stop * ca * 0.5
|
||||
if k.close < donchian_exit_low or k.close < max(stop, trail_stop):
|
||||
self._side = ""
|
||||
reason = "跌破10日低点" if k.close < donchian_exit_low else "ATR止损"
|
||||
return Signal(symbol=self.cfg.symbol, side="SELL", reason=reason, timestamp=k.open_time, confidence=0.25)
|
||||
|
||||
elif self._side == "short":
|
||||
self._lowest_since_entry = min(self._lowest_since_entry, k.low)
|
||||
stop = self._entry_price + self.cfg.atr_stop * ca
|
||||
trail_stop = self._lowest_since_entry + self.cfg.atr_stop * ca * 0.5
|
||||
if k.close > donchian_exit_high or k.close > min(stop, trail_stop):
|
||||
self._side = ""
|
||||
reason = "突破10日高点" if k.close > donchian_exit_high else "ATR止损"
|
||||
return Signal(symbol=self.cfg.symbol, side="BUY", reason=reason, timestamp=k.open_time, confidence=0.25)
|
||||
|
||||
# ── 入场:仅在有明显突破幅度时入场 ──
|
||||
else:
|
||||
breakout_margin = 0.002 # 需突破通道 0.2% 以上
|
||||
if k.close > donchian_high * (1 + breakout_margin):
|
||||
self._side = "long"
|
||||
self._entry_price = k.close
|
||||
self._highest_since_entry = k.close
|
||||
return Signal(symbol=self.cfg.symbol, side="BUY", reason="突破20日高点", timestamp=k.open_time, confidence=0.25)
|
||||
elif k.close < donchian_low * (1 - breakout_margin):
|
||||
self._side = "short"
|
||||
self._entry_price = k.close
|
||||
self._lowest_since_entry = 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 动态跟踪止损,趋势翻转即反转
|
||||
# ════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class SuperTrendConfig(StrategyConfig):
|
||||
atr_period: int = 10
|
||||
multiplier: float = 3.0
|
||||
|
||||
|
||||
class SuperTrendStrategy(BaseStrategy):
|
||||
"""超级趋势 — ATR 动态跟踪止损,趋势跟踪"""
|
||||
|
||||
strategy_type = "supertrend"
|
||||
|
||||
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 # 1=多, -1=空
|
||||
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 prev_trend == prev_trend: # always true, just need placeholder
|
||||
pass
|
||||
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
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 策略 3:MACD 金叉死叉
|
||||
# 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):
|
||||
"""MACD 金叉死叉 — 零轴以上只做多,零轴以下只做空"""
|
||||
|
||||
strategy_type = "macd_cross"
|
||||
|
||||
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] = [] # MACD 线值
|
||||
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
|
||||
|
||||
# 信号线 = EMA of MACD,简化:用列表算
|
||||
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 = self._macd_vals[-1]
|
||||
cur_s = self._signal_vals[-1]
|
||||
prev_m = self._macd_vals[-2]
|
||||
prev_s = 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
|
||||
reason = "ATR止损" if k.close < stop else "MACD死叉"
|
||||
return Signal(symbol=self.cfg.symbol, side="SELL", reason=reason, 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
|
||||
reason = "ATR止损" if k.close > stop else "MACD金叉"
|
||||
return Signal(symbol=self.cfg.symbol, side="BUY", reason=reason, 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):
|
||||
"""布林收缩爆发 — BB 收缩到极限后扩张,顺势入场"""
|
||||
|
||||
strategy_type = "bb_squeeze"
|
||||
|
||||
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) # Keltner 中轨
|
||||
self._atr_kc = AtrInc(c.kc_period) # Keltner 宽度的 ATR
|
||||
self._atr_stop = AtrInc(14) # 止损 ATR
|
||||
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)
|
||||
|
||||
# BB 在 KC 内部 = 收缩
|
||||
is_squeezed = bb_u < kc_u and bb_l > kc_l
|
||||
# BB 宽度处于近期最低水平
|
||||
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
|
||||
|
||||
# 收缩释放信号:之前收缩,现在 BB 扩张出 KC
|
||||
was_squeezed = self._was_squeezed
|
||||
fired = False
|
||||
if is_squeezed:
|
||||
self._was_squeezed = True
|
||||
self._squeeze_bars += 1
|
||||
elif self._was_squeezed:
|
||||
# BB 不再在 KC 内部 → 收缩释放
|
||||
self._was_squeezed = False
|
||||
self._squeeze_bars = 0
|
||||
fired = True
|
||||
|
||||
# 方向判断:用价格与 BB 中轨关系 + EMA(5) 动量
|
||||
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="ATR止损或转弱", 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="ATR止损或转强", 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 = "triple_ema"
|
||||
|
||||
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_entry: float = 0.0
|
||||
self._lowest_since_entry: 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 = self._ema_fast[-1]
|
||||
em = self._ema_mid[-1]
|
||||
es = self._ema_slow[-1]
|
||||
pf = self._ema_fast[-2]
|
||||
pm = 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_entry = max(self._highest_since_entry, k.high)
|
||||
trail = self._highest_since_entry - self.cfg.atr_stop * ca
|
||||
if fast_cross_mid_down or k.close < trail:
|
||||
self._side = ""
|
||||
reason = "快线下穿中线" if fast_cross_mid_down else "ATR追踪止损"
|
||||
return Signal(symbol=self.cfg.symbol, side="SELL", reason=reason, timestamp=k.open_time, confidence=0.25)
|
||||
|
||||
# ── 空头持仓 ──
|
||||
elif self._side == "short":
|
||||
self._lowest_since_entry = min(self._lowest_since_entry, k.low)
|
||||
trail = self._lowest_since_entry + self.cfg.atr_stop * ca
|
||||
if fast_cross_mid_up or k.close > trail:
|
||||
self._side = ""
|
||||
reason = "快线上穿中线" if fast_cross_mid_up else "ATR追踪止损"
|
||||
return Signal(symbol=self.cfg.symbol, side="BUY", reason=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_entry = 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_entry = k.close
|
||||
return Signal(symbol=self.cfg.symbol, side="SELL", reason="三线空头排列+快线死叉", timestamp=k.open_time, confidence=0.25)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
# 执行
|
||||
# ════════════════════════════════════════════════════════
|
||||
|
||||
STRATEGIES = {
|
||||
"1.海龟交易(Turtle)": (TurtleConfig, TurtleStrategy),
|
||||
"2.超级趋势(SuperTrend)": (SuperTrendConfig, SuperTrendStrategy),
|
||||
"3.MACD金叉死叉": (MacdCrossConfig, MacdCrossStrategy),
|
||||
"4.布林收缩爆发(BBSqueeze)": (BBSqueezeConfig, BBSqueezeStrategy),
|
||||
"5.三均线排列(TripleEMA)": (TripleEmaConfig, TripleEmaStrategy),
|
||||
}
|
||||
|
||||
|
||||
def make_config(config_cls, symbol):
|
||||
"""根据策略类型创建默认参数配置"""
|
||||
if config_cls == TurtleConfig:
|
||||
return config_cls(symbol=symbol, entry_period=20, exit_period=10, atr_period=20, atr_stop=2.0)
|
||||
elif config_cls == SuperTrendConfig:
|
||||
return config_cls(symbol=symbol, atr_period=10, multiplier=3.0)
|
||||
elif config_cls == MacdCrossConfig:
|
||||
return config_cls(symbol=symbol, fast=12, slow=26, signal=9, atr_period=14, atr_stop=2.0)
|
||||
elif config_cls == BBSqueezeConfig:
|
||||
return config_cls(symbol=symbol, bb_period=20, bb_std=2.0, kc_period=20, kc_mult=1.5, squeeze_lookback=30, atr_stop=2.0)
|
||||
elif config_cls == TripleEmaConfig:
|
||||
return config_cls(symbol=symbol, fast=10, mid=30, slow=60, atr_period=14, atr_stop=2.0)
|
||||
else:
|
||||
raise ValueError(f"未知策略: {config_cls}")
|
||||
|
||||
|
||||
async def run_one(config_cls, strategy_cls, symbol, start, end):
|
||||
sc = make_config(config_cls, symbol)
|
||||
bt = BacktestConfig(symbol=symbol, interval=INTERVAL, start_time=start, end_time=end, initial_capital=INITIAL)
|
||||
engine = LongShortEngine(bt, db_config=config.db)
|
||||
r = await engine.run(strategy_cls, sc)
|
||||
return r
|
||||
|
||||
|
||||
async def main():
|
||||
ds = DataService(config.db)
|
||||
await ds.connect()
|
||||
|
||||
# 获取数据范围
|
||||
ranges: dict[str, tuple] = {}
|
||||
for symbol in SYMBOLS:
|
||||
try:
|
||||
s, e = await ds.fetch_symbol_date_range(symbol, INTERVAL)
|
||||
ranges[symbol] = (s, e)
|
||||
print(f" {symbol}: {s.date()} ~ {e.date()}")
|
||||
except Exception as ex:
|
||||
print(f" {symbol}: 获取范围失败 {ex}")
|
||||
|
||||
await ds.close()
|
||||
|
||||
# 汇总数据
|
||||
all_results: list[dict] = []
|
||||
detail_results: dict[str, dict[str, dict]] = {} # 用于保存详细结果
|
||||
|
||||
print()
|
||||
print("═" * 140)
|
||||
print(" 5 策略 × 4 币种 × 1h — 网络验证策略扫描")
|
||||
print("═" * 140)
|
||||
print()
|
||||
|
||||
for strategy_name, (config_cls, strategy_cls) in STRATEGIES.items():
|
||||
print(f" ■ {strategy_name}")
|
||||
print(f" {'币种':<10} {'本金':>7} {'终值':>9} {'总收益%':>8} {'年化%':>8} {'夏普':>7} {'回撤%':>8} {'胜率%':>7} {'盈亏比':>7} {'交易':>6} {'耗时s':>7}")
|
||||
print(" " + "─" * 120)
|
||||
|
||||
detail_results[strategy_name] = {}
|
||||
|
||||
for symbol in SYMBOLS:
|
||||
if symbol not in ranges:
|
||||
continue
|
||||
start, end = ranges[symbol]
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
r = await run_one(config_cls, strategy_cls, symbol, start, end)
|
||||
elapsed = time.time() - t0
|
||||
except Exception as ex:
|
||||
print(f" {symbol:<10} {'错误: ' + str(ex)[:50]}")
|
||||
continue
|
||||
|
||||
m = r.metrics
|
||||
final = m.final_equity
|
||||
print(f" {symbol:<10} {INITIAL:>7.0f} {final:>9.0f} {m.total_return_pct:>7.1f}% {m.annual_return_pct:>7.1f}% {m.sharpe_ratio:>7.2f} {m.max_drawdown_pct:>7.1f}% {m.win_rate*100:>6.1f}% {m.profit_factor:>7.2f} {m.total_trades:>6} {elapsed:>6.1f}s")
|
||||
|
||||
all_results.append({
|
||||
"strategy": strategy_name,
|
||||
"symbol": symbol,
|
||||
"interval": INTERVAL,
|
||||
"initial": INITIAL,
|
||||
"final": final,
|
||||
"total_return": m.total_return_pct,
|
||||
"annual_return": m.annual_return_pct,
|
||||
"sharpe": m.sharpe_ratio,
|
||||
"drawdown": m.max_drawdown_pct,
|
||||
"win_rate": m.win_rate * 100,
|
||||
"profit_factor": m.profit_factor,
|
||||
"trades": m.total_trades,
|
||||
"best": m.best_trade_pnl,
|
||||
"worst": m.worst_trade_pnl,
|
||||
"avg": m.avg_trade_pnl,
|
||||
"calmar": m.calmar_ratio,
|
||||
"start_date": str(start.date()),
|
||||
"end_date": str(end.date()),
|
||||
})
|
||||
|
||||
# 详细交易记录
|
||||
detail_results[strategy_name][symbol] = {
|
||||
"config": {
|
||||
"symbol": symbol,
|
||||
"interval": INTERVAL,
|
||||
"start": str(start.date()),
|
||||
"end": str(end.date()),
|
||||
"initial_capital": INITIAL,
|
||||
},
|
||||
"metrics": {
|
||||
"total_return_pct": m.total_return_pct,
|
||||
"annual_return_pct": m.annual_return_pct,
|
||||
"sharpe_ratio": m.sharpe_ratio,
|
||||
"max_drawdown_pct": m.max_drawdown_pct,
|
||||
"win_rate": m.win_rate * 100,
|
||||
"profit_factor": m.profit_factor,
|
||||
"total_trades": m.total_trades,
|
||||
"avg_trade_pnl": m.avg_trade_pnl,
|
||||
"best_trade_pnl": m.best_trade_pnl,
|
||||
"worst_trade_pnl": m.worst_trade_pnl,
|
||||
"final_equity": m.final_equity,
|
||||
"calmar_ratio": m.calmar_ratio,
|
||||
},
|
||||
"trades": [
|
||||
{
|
||||
"side": t.side,
|
||||
"price": t.price,
|
||||
"quantity": t.quantity,
|
||||
"pnl": t.pnl,
|
||||
"reason": t.reason,
|
||||
"timestamp": t.timestamp,
|
||||
"timestamp_str": datetime.fromtimestamp(t.timestamp / 1000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M"),
|
||||
}
|
||||
for t in r.trades
|
||||
],
|
||||
}
|
||||
print()
|
||||
|
||||
# ── 汇总:每种策略的最佳/最差 ──
|
||||
print("═" * 140)
|
||||
print(" ■ 各策略汇总 (按年化收益排序)")
|
||||
print(f" {'策略':<28} {'币种':<10} {'本金':>7} {'终值':>9} {'总收益%':>8} {'年化%':>8} {'夏普':>7} {'回撤%':>8} {'胜率%':>7} {'盈亏比':>7} {'交易':>6}")
|
||||
print(" " + "─" * 120)
|
||||
|
||||
for sn in STRATEGIES:
|
||||
candidates = [r for r in all_results if r["strategy"] == sn]
|
||||
if not candidates:
|
||||
continue
|
||||
# 按年化排序,显示所有币种
|
||||
candidates.sort(key=lambda x: x["annual_return"], reverse=True)
|
||||
for c in candidates:
|
||||
marker = " ★" if c == candidates[0] else " "
|
||||
print(f" {sn:<26}{marker} {c['symbol']:<10} {c['initial']:>7.0f} {c['final']:>9.0f} {c['total_return']:>7.1f}% {c['annual_return']:>7.1f}% {c['sharpe']:>7.2f} {c['drawdown']:>7.1f}% {c['win_rate']:>6.1f}% {c['profit_factor']:>7.2f} {c['trades']:>6}")
|
||||
|
||||
print("\n═" * 140)
|
||||
|
||||
# ── 保存结果到 JSON ──
|
||||
output_file = _project_root / "engine" / "example" / "web_strategies_result.json"
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"summary": all_results,
|
||||
"detail": detail_results,
|
||||
"run_time": datetime.now(timezone.utc).isoformat(),
|
||||
}, f, ensure_ascii=False, indent=2, default=str)
|
||||
print(f"\n 详细结果已保存至: {output_file}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user