feat(engine): 添加策略示例集(18 个 Demo)
- backtest_demo.py: 回测基础演示 - strategy_simple.py / three_ema.py / long_short.py: 基础策略(双均线/三均线/多空) - strategy_optimize*.py (3 版本): 参数优化示例(网格搜索/贝叶斯/遗传算法) - multi_tf_*.py (4 版本): 多时间框架策略(EMA200/多周期共振/混合信号) - regime_*.py (4 版本): 市场状态检测(趋势/震荡/波动率区间/全状态) - cross_section.py: 截面多品种策略 - factor_demo.py: 多因子模型演示 - strategy_battle.py / strategy_more.py: 策略对比与组合 - full_cycle.py: 全流程演示(数据→回测→分析) - data.py: 数据读取示例
This commit is contained in:
@@ -0,0 +1,218 @@
|
|||||||
|
"""
|
||||||
|
回测引擎使用示例 — 双策略演示
|
||||||
|
|
||||||
|
用法:
|
||||||
|
source .venv/bin/activate && python example/backtest_demo.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
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
|
||||||
|
from engine.indicators import sma, rsi
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 策略 1:双均线交叉
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class MACrossConfig(StrategyConfig):
|
||||||
|
fast_period: int = 7
|
||||||
|
slow_period: int = 25
|
||||||
|
|
||||||
|
|
||||||
|
class MACrossStrategy(BaseStrategy):
|
||||||
|
"""双均线交叉策略 — 使用 engine.indicators.sma 计算均线"""
|
||||||
|
|
||||||
|
strategy_type = "ma_cross"
|
||||||
|
|
||||||
|
def __init__(self, config: MACrossConfig):
|
||||||
|
super().__init__(config)
|
||||||
|
self.config: MACrossConfig = config
|
||||||
|
self._closes: list[float] = []
|
||||||
|
self._last_signal: Optional[str] = None
|
||||||
|
|
||||||
|
async def on_kline(self, kline: Kline) -> Optional[Signal]:
|
||||||
|
self._closes.append(kline.close)
|
||||||
|
|
||||||
|
# 使用指标库计算 SMA
|
||||||
|
fast_ma = sma(self._closes, self.config.fast_period)
|
||||||
|
slow_ma = sma(self._closes, self.config.slow_period)
|
||||||
|
fast = fast_ma[-1]
|
||||||
|
slow = slow_ma[-1]
|
||||||
|
|
||||||
|
if fast == 0.0 or slow == 0.0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if fast > slow and self._last_signal != "BUY":
|
||||||
|
self._last_signal = "BUY"
|
||||||
|
return Signal(
|
||||||
|
symbol=self.config.symbol,
|
||||||
|
side="BUY",
|
||||||
|
signal_type="MARKET",
|
||||||
|
confidence=0.8,
|
||||||
|
reason=f"金叉 MA{self.config.fast_period}>{self.config.slow_period}",
|
||||||
|
timestamp=kline.open_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
if fast < slow and self._last_signal != "SELL":
|
||||||
|
self._last_signal = "SELL"
|
||||||
|
return Signal(
|
||||||
|
symbol=self.config.symbol,
|
||||||
|
side="SELL",
|
||||||
|
signal_type="MARKET",
|
||||||
|
confidence=0.8,
|
||||||
|
reason=f"死叉 MA{self.config.fast_period}<{self.config.slow_period}",
|
||||||
|
timestamp=kline.open_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 策略 2:RSI 超买超卖
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class RSIStrategyConfig(StrategyConfig):
|
||||||
|
period: int = 14
|
||||||
|
oversold: float = 30.0 # 超卖阈值
|
||||||
|
overbought: float = 70.0 # 超买阈值
|
||||||
|
|
||||||
|
|
||||||
|
class RSIStrategy(BaseStrategy):
|
||||||
|
"""RSI 超买超卖策略 — 使用 engine.indicators.rsi 计算 RSI
|
||||||
|
|
||||||
|
RSI 低于超卖线 → 买入;RSI 高于超买线 → 卖出。
|
||||||
|
"""
|
||||||
|
|
||||||
|
strategy_type = "rsi"
|
||||||
|
|
||||||
|
def __init__(self, config: RSIStrategyConfig):
|
||||||
|
super().__init__(config)
|
||||||
|
self.config: RSIStrategyConfig = config
|
||||||
|
self._closes: list[float] = []
|
||||||
|
self._has_position = False
|
||||||
|
|
||||||
|
async def on_kline(self, kline: Kline) -> Optional[Signal]:
|
||||||
|
self._closes.append(kline.close)
|
||||||
|
|
||||||
|
# 使用指标库计算 RSI
|
||||||
|
rsi_vals = rsi(self._closes, self.config.period)
|
||||||
|
current_rsi = rsi_vals[-1]
|
||||||
|
|
||||||
|
if current_rsi == 0.0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 超卖 → 买入
|
||||||
|
if current_rsi < self.config.oversold and not self._has_position:
|
||||||
|
self._has_position = True
|
||||||
|
return Signal(
|
||||||
|
symbol=self.config.symbol,
|
||||||
|
side="BUY",
|
||||||
|
signal_type="MARKET",
|
||||||
|
confidence=0.7,
|
||||||
|
reason=f"RSI超卖 ({current_rsi:.1f} < {self.config.oversold})",
|
||||||
|
timestamp=kline.open_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 超买 → 卖出
|
||||||
|
if current_rsi > self.config.overbought and self._has_position:
|
||||||
|
self._has_position = False
|
||||||
|
return Signal(
|
||||||
|
symbol=self.config.symbol,
|
||||||
|
side="SELL",
|
||||||
|
signal_type="MARKET",
|
||||||
|
confidence=0.7,
|
||||||
|
reason=f"RSI超买 ({current_rsi:.1f} > {self.config.overbought})",
|
||||||
|
timestamp=kline.open_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 主函数
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def run_backtest(
|
||||||
|
engine: BacktestEngine,
|
||||||
|
strategy_cls,
|
||||||
|
strategy_config: StrategyConfig,
|
||||||
|
label: str,
|
||||||
|
):
|
||||||
|
"""运行一次回测并打印结果"""
|
||||||
|
print(f"\n{'━' * 60}")
|
||||||
|
print(f" {label}")
|
||||||
|
print(f"{'━' * 60}")
|
||||||
|
|
||||||
|
result = await engine.run(strategy_cls, strategy_config)
|
||||||
|
print(result.summary())
|
||||||
|
|
||||||
|
# 最近 5 笔交易
|
||||||
|
if result.trades:
|
||||||
|
print(f"\n 最近 5 笔交易:")
|
||||||
|
print(f" {'时间':<22} {'方向':<6} {'价格':>10} {'数量':>10} {'盈亏':>10} 原因")
|
||||||
|
for t in result.trades[-5:]:
|
||||||
|
dt = datetime.fromtimestamp(t.timestamp / 1000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
|
||||||
|
pnl_str = f"{t.pnl:+.4f}" if t.pnl is not None else "—"
|
||||||
|
print(f" {dt:<22} {t.side:<6} {t.price:>10.4f} {t.quantity:>10.6f} {pnl_str:>10} {t.reason}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# ── 回测配置 ──
|
||||||
|
bt_config = BacktestConfig(
|
||||||
|
symbol="ETHUSDT",
|
||||||
|
interval="4h",
|
||||||
|
start_time=datetime(2024, 1, 1),
|
||||||
|
end_time=datetime(2026, 1, 1),
|
||||||
|
initial_capital=10_000.0,
|
||||||
|
commission_pct=0.001,
|
||||||
|
slippage_pct=0.0005,
|
||||||
|
warmup_bars=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\n回测: {bt_config.symbol} {bt_config.interval}")
|
||||||
|
print(f"时间: {bt_config.start_time.date()} ~ {bt_config.end_time.date()}")
|
||||||
|
print(f"初始资金: {bt_config.initial_capital:.2f} USDT")
|
||||||
|
|
||||||
|
engine = BacktestEngine(bt_config, db_config=config.db)
|
||||||
|
|
||||||
|
# ── 策略 1:双均线交叉 ──
|
||||||
|
ma_config = MACrossConfig(
|
||||||
|
name="ma_cross_eth",
|
||||||
|
symbol="ETHUSDT",
|
||||||
|
fast_period=7,
|
||||||
|
slow_period=25,
|
||||||
|
)
|
||||||
|
await run_backtest(engine, MACrossStrategy, ma_config, "策略 1:双均线交叉 (MA7/MA25)")
|
||||||
|
|
||||||
|
# ── 策略 2:RSI 超买超卖 ──
|
||||||
|
rsi_config = RSIStrategyConfig(
|
||||||
|
name="rsi_eth",
|
||||||
|
symbol="ETHUSDT",
|
||||||
|
period=14,
|
||||||
|
oversold=30.0,
|
||||||
|
overbought=70.0,
|
||||||
|
)
|
||||||
|
await run_backtest(engine, RSIStrategy, rsi_config, "策略 2:RSI 超买超卖 (30/70)")
|
||||||
|
|
||||||
|
print("\n全部回测完成。")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""
|
||||||
|
横截面动量 — 选强弃弱 + 趋势/均值回归入场
|
||||||
|
|
||||||
|
策略:
|
||||||
|
1. 每根 4h K 线,计算 4 个币种过去 N 根 K 线的收益率
|
||||||
|
2. 按收益率排名,只有前 2 名允许做多
|
||||||
|
3. 趋势入场:EMA(10,50) 金叉 + 排名前2 → 买入
|
||||||
|
4. 回归入场:RSI < 35 + 排名前2 → 回调买入
|
||||||
|
5. 出场:排名跌出前2 或 EMA死叉 或 ATR止损
|
||||||
|
|
||||||
|
币种:BTC/ETH/BNB/SOL | 4h | 2024-2026
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
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
|
||||||
|
from engine.data import DataService
|
||||||
|
from engine.indicators import ema, atr, rsi as calc_rsi
|
||||||
|
|
||||||
|
|
||||||
|
ALL_SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
|
||||||
|
|
||||||
|
|
||||||
|
class CrossSectionConfig(StrategyConfig):
|
||||||
|
lookback: int = 20 # 排名回溯周期
|
||||||
|
rank_threshold: int = 2 # 只做前N名
|
||||||
|
ema_fast: int = 10
|
||||||
|
ema_slow: int = 50
|
||||||
|
rsi_period: int = 14
|
||||||
|
rsi_entry: float = 35.0
|
||||||
|
atr_stop: float = 2.5
|
||||||
|
data_start: Optional[datetime] = None
|
||||||
|
data_end: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CrossSectionStrategy(BaseStrategy):
|
||||||
|
"""横截面动量 — 只做强势币种"""
|
||||||
|
|
||||||
|
strategy_type = "cross_section"
|
||||||
|
|
||||||
|
def __init__(self, c: CrossSectionConfig):
|
||||||
|
super().__init__(c)
|
||||||
|
self.cfg = c
|
||||||
|
# 所有币种的数据 {symbol: [Kline]}
|
||||||
|
self._all_klines: dict[str, list[Kline]] = {}
|
||||||
|
self._all_closes: dict[str, list[float]] = {}
|
||||||
|
# 当前币种的数据
|
||||||
|
self._closes: list[float] = []
|
||||||
|
self._highs: list[float] = []
|
||||||
|
self._lows: list[float] = []
|
||||||
|
self._highest: float = 0.0
|
||||||
|
self._in_position = False
|
||||||
|
|
||||||
|
async def on_start(self):
|
||||||
|
from engine.common.config import config as app_config
|
||||||
|
ds = DataService(app_config.db)
|
||||||
|
await ds.connect()
|
||||||
|
try:
|
||||||
|
for sym in ALL_SYMBOLS:
|
||||||
|
klines = await ds.fetch_klines(
|
||||||
|
symbol=sym, interval="4h",
|
||||||
|
start_time=self.cfg.data_start, end_time=self.cfg.data_end,
|
||||||
|
limit=1_000_000,
|
||||||
|
)
|
||||||
|
self._all_klines[sym] = klines
|
||||||
|
self._all_closes[sym] = [k.close for k in klines]
|
||||||
|
finally:
|
||||||
|
await ds.close()
|
||||||
|
await super().on_start()
|
||||||
|
|
||||||
|
def _get_rank(self, ts: float) -> dict[str, float]:
|
||||||
|
"""计算所有币种在指定时间戳的排名收益率,返回 {symbol: return%}"""
|
||||||
|
scores = {}
|
||||||
|
for sym in ALL_SYMBOLS:
|
||||||
|
klines = self._all_klines.get(sym, [])
|
||||||
|
if not klines:
|
||||||
|
scores[sym] = -999
|
||||||
|
continue
|
||||||
|
# 找到时间戳 <= ts 的最新K线索引
|
||||||
|
idx = len(klines) - 1
|
||||||
|
for i in range(len(klines) - 1, -1, -1):
|
||||||
|
if klines[i].open_time <= ts:
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
# 计算过去 lookback 根K线的收益率
|
||||||
|
start_idx = max(0, idx - self.cfg.lookback)
|
||||||
|
if start_idx >= idx:
|
||||||
|
scores[sym] = 0
|
||||||
|
else:
|
||||||
|
start_price = self._all_closes[sym][start_idx]
|
||||||
|
end_price = self._all_closes[sym][idx]
|
||||||
|
scores[sym] = (end_price / start_price - 1) * 100 if start_price > 0 else 0
|
||||||
|
return scores
|
||||||
|
|
||||||
|
def _my_rank(self, ts: float) -> int:
|
||||||
|
"""当前币种在全部币种中的排名(1=最强)"""
|
||||||
|
scores = self._get_rank(ts)
|
||||||
|
my_score = scores.get(self.cfg.symbol, -999)
|
||||||
|
# 高于我的分数有几个
|
||||||
|
better = sum(1 for s in scores.values() if s > my_score)
|
||||||
|
return better + 1
|
||||||
|
|
||||||
|
async def on_kline(self, k: Kline) -> Optional[Signal]:
|
||||||
|
self._closes.append(k.close)
|
||||||
|
self._highs.append(k.high)
|
||||||
|
self._lows.append(k.low)
|
||||||
|
n = len(self._closes)
|
||||||
|
if n < self.cfg.ema_slow + 10:
|
||||||
|
return None
|
||||||
|
|
||||||
|
fast = ema(self._closes, self.cfg.ema_fast)
|
||||||
|
slow = ema(self._closes, self.cfg.ema_slow)
|
||||||
|
atr_vals = atr(self._highs, self._lows, self._closes, 14)
|
||||||
|
rsi_vals = calc_rsi(self._closes, self.cfg.rsi_period)
|
||||||
|
|
||||||
|
cur_f, cur_s = fast[-1], slow[-1]
|
||||||
|
prev_f, prev_s = fast[-2], slow[-2]
|
||||||
|
cur_atr = atr_vals[-1]
|
||||||
|
cur_rsi = rsi_vals[-1]
|
||||||
|
if cur_f == 0 or cur_s == 0 or cur_atr == 0 or cur_rsi == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
rank = self._my_rank(k.open_time)
|
||||||
|
is_top = rank <= self.cfg.rank_threshold
|
||||||
|
|
||||||
|
golden = prev_f <= prev_s and cur_f > cur_s # 趋势入场
|
||||||
|
death = prev_f >= prev_s and cur_f < cur_s # 趋势出场
|
||||||
|
oversold = cur_rsi < self.cfg.rsi_entry # 均值回归入场
|
||||||
|
|
||||||
|
# ── 出场 ──
|
||||||
|
if self._in_position:
|
||||||
|
self._highest = max(self._highest, k.high)
|
||||||
|
stop = self._highest - self.cfg.atr_stop * cur_atr
|
||||||
|
|
||||||
|
if not is_top:
|
||||||
|
self._in_position = False
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL",
|
||||||
|
reason=f"排名跌出前{self.cfg.rank_threshold}(#{rank})", timestamp=k.open_time)
|
||||||
|
if death:
|
||||||
|
self._in_position = False
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason="EMA死叉", timestamp=k.open_time)
|
||||||
|
if k.close < stop:
|
||||||
|
self._in_position = False
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason="ATR止损", timestamp=k.open_time)
|
||||||
|
|
||||||
|
# ── 入场 ──
|
||||||
|
if not self._in_position and is_top:
|
||||||
|
# 趋势信号:金叉
|
||||||
|
if golden:
|
||||||
|
self._in_position = True
|
||||||
|
self._highest = k.close
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="BUY",
|
||||||
|
reason=f"金叉+#{rank}横截面动量", timestamp=k.open_time)
|
||||||
|
# 回归信号:RSI超卖
|
||||||
|
if oversold:
|
||||||
|
self._in_position = True
|
||||||
|
self._highest = k.close
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="BUY",
|
||||||
|
confidence=0.7, # 回归信号稍降仓位
|
||||||
|
reason=f"RSI超卖+#{rank}横截面动量 RSI={cur_rsi:.0f}",
|
||||||
|
timestamp=k.open_time)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
DATE_START = datetime(2024, 1, 1)
|
||||||
|
DATE_END = datetime(2026, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print()
|
||||||
|
print("═" * 110)
|
||||||
|
print(" 横截面动量 — 只做最强 + 趋势/回归双信号 | 4h | 2024-2026")
|
||||||
|
print("═" * 110)
|
||||||
|
print(f" {'币种':<10} {'收益%':>7} {'夏普':>6} {'回撤%':>7} {'交易':>5} {'胜率%':>6} {'盈亏比':>6}")
|
||||||
|
print("─" * 110)
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for symbol in ALL_SYMBOLS:
|
||||||
|
sc = CrossSectionConfig(symbol=symbol, data_start=DATE_START, data_end=DATE_END)
|
||||||
|
bt = BacktestConfig(symbol=symbol, interval="4h",
|
||||||
|
start_time=DATE_START, end_time=DATE_END, initial_capital=10_000.0)
|
||||||
|
engine = BacktestEngine(bt, db_config=config.db)
|
||||||
|
r = await engine.run(CrossSectionStrategy, sc)
|
||||||
|
m = r.metrics
|
||||||
|
results[symbol] = (m, r)
|
||||||
|
|
||||||
|
# 统计排名分布和信号类型
|
||||||
|
trend_signals = sum(1 for t in r.trades if t.side == "BUY" and "金叉" in t.reason)
|
||||||
|
meanrev_signals = sum(1 for t in r.trades if t.side == "BUY" and "RSI" in t.reason)
|
||||||
|
exits_rank = sum(1 for t in r.trades if t.side == "SELL" and "排名" in t.reason)
|
||||||
|
|
||||||
|
print(f" {symbol:<10} {m.total_return_pct:>6.1f}% {m.sharpe_ratio:>6.2f} {m.max_drawdown_pct:>6.1f}% {m.total_trades:>5} {m.win_rate*100:>5.1f}% {m.profit_factor:>6.2f}")
|
||||||
|
print(f" {'':<10} └ 趋势入场:{trend_signals} 回归入场:{meanrev_signals} 排名出场:{exits_rank}")
|
||||||
|
|
||||||
|
sells = [t for t in r.trades if t.side == "SELL" and t.pnl is not None]
|
||||||
|
for t in sells[-2:]:
|
||||||
|
dt = datetime.fromtimestamp(t.timestamp / 1000, tz=timezone.utc).strftime("%m-%d %H:%M")
|
||||||
|
print(f" {'':<10} └ {dt} {t.pnl:>+8.2f} {t.reason}")
|
||||||
|
|
||||||
|
# ── 对比 ──
|
||||||
|
print("─" * 110)
|
||||||
|
print("\n ■ 对比:纯趋势跟踪 vs 横截面动量")
|
||||||
|
TREND = {
|
||||||
|
"BTCUSDT": ("EMA v3(10,50)", 39.9, 1.03, 20),
|
||||||
|
"ETHUSDT": ("EMA v3(10,75)", 53.6, 1.04, 18),
|
||||||
|
"BNBUSDT": ("EMA v1(20,50)", 52.0, 0.71, 41),
|
||||||
|
"SOLUSDT": ("EMA v3(30,50)", 73.6, 1.18, 13),
|
||||||
|
}
|
||||||
|
print(f" {'币种':<10} {'纯趋势':>24} → {'横截面动量':>24}")
|
||||||
|
print(f" {'':<10} {'收益% 夏普 交易':>24} → {'收益% 夏普 交易':>24}")
|
||||||
|
for sym in ALL_SYMBOLS:
|
||||||
|
t_name, t_ret, t_sh, t_tr = TREND[sym]
|
||||||
|
m, r = results[sym]
|
||||||
|
print(f" {sym:<10} {t_ret:>5.1f}% {t_sh:>5.2f} {t_tr:>4}次 → {m.total_return_pct:>5.1f}% {m.sharpe_ratio:>5.2f} {m.total_trades:>4}次")
|
||||||
|
|
||||||
|
print("\n═" * 110)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""
|
||||||
|
DataService 使用示例 — 读取各周期 K 线并打印
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python example/data.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 确保项目根目录在 sys.path 中,以便使用 engine 包导入
|
||||||
|
_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.data import DataService
|
||||||
|
from engine.common.config import config
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
|
||||||
|
print(config.db)
|
||||||
|
|
||||||
|
ds = DataService(config.db)
|
||||||
|
await ds.connect()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. 查看可用交易对
|
||||||
|
symbols = await ds.fetch_available_symbols("1m")
|
||||||
|
print(f"可用交易对: {symbols}")
|
||||||
|
|
||||||
|
# 2. 各周期读取最新 3 条 BTCUSDT K 线
|
||||||
|
target = "BTCUSDT"
|
||||||
|
for interval in ["1m", "5m", "15m", "30m", "1h", "4h", "1d"]:
|
||||||
|
klines = await ds.fetch_klines(
|
||||||
|
symbol=target,
|
||||||
|
interval=interval,
|
||||||
|
limit=3,
|
||||||
|
)
|
||||||
|
print(f"\n{'─' * 70}")
|
||||||
|
print(f" [{interval}] {target} — {len(klines)} 条")
|
||||||
|
print(f"{'─' * 70}")
|
||||||
|
for k in klines:
|
||||||
|
print(
|
||||||
|
f" {k.open_time:.0f} O={k.open:>12.4f} H={k.high:>12.4f}"
|
||||||
|
f" L={k.low:>12.4f} C={k.close:>12.4f}"
|
||||||
|
f" V={k.volume:>10.4f} trades={k.trade_count}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 日期范围
|
||||||
|
start, end = await ds.fetch_symbol_date_range(target, "1d")
|
||||||
|
print(f"\n{target} 1d 数据范围: {start} ~ {end}")
|
||||||
|
|
||||||
|
print("\n完成")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
await ds.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"""
|
||||||
|
多因子组合回测 — 三重共振策略
|
||||||
|
|
||||||
|
随机挑选 3 个技术指标组合成一个策略:
|
||||||
|
- MACD (趋势因子) — 金叉/死叉判断方向
|
||||||
|
- RSI (动量因子) — 阈值过滤避免追高抄底
|
||||||
|
- Bollinger (波动率因子) — 中轨确认趋势强度
|
||||||
|
|
||||||
|
用法:
|
||||||
|
source .venv/bin/activate && python example/factor_demo.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
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
|
||||||
|
from engine.indicators import macd, rsi, bollinger, atr
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 三重共振策略
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TripleFactorConfig(StrategyConfig):
|
||||||
|
"""三重因子组合策略配置"""
|
||||||
|
|
||||||
|
# MACD
|
||||||
|
macd_fast: int = 12
|
||||||
|
macd_slow: int = 26
|
||||||
|
macd_signal: int = 9
|
||||||
|
|
||||||
|
# RSI
|
||||||
|
rsi_period: int = 14
|
||||||
|
rsi_oversold: float = 30.0 # 超卖线(入场需在此之上)
|
||||||
|
rsi_overbought: float = 65.0 # 入场过热线(入场需在此之下)
|
||||||
|
rsi_exit: float = 75.0 # 卖出线
|
||||||
|
|
||||||
|
# Bollinger
|
||||||
|
bb_period: int = 20
|
||||||
|
bb_std: float = 2.0
|
||||||
|
|
||||||
|
# ATR 动态止损倍数(0 表示不启用)
|
||||||
|
atr_period: int = 14
|
||||||
|
atr_stop_mult: float = 2.0
|
||||||
|
|
||||||
|
|
||||||
|
class TripleFactorStrategy(BaseStrategy):
|
||||||
|
"""三重共振策略
|
||||||
|
|
||||||
|
┌─────────────┬──────────────────────────────────────┐
|
||||||
|
│ 因子 │ 作用 │
|
||||||
|
├─────────────┼──────────────────────────────────────┤
|
||||||
|
│ MACD (趋势) │ 金叉=看多入场信号,死叉=看空出场信号 │
|
||||||
|
│ RSI (动量) │ 30<RSI<65 区间入场,RSI>75 过热出场 │
|
||||||
|
│ BB (波动) │ 价格>中轨确认多头趋势,跌破下轨出场 │
|
||||||
|
└─────────────┴──────────────────────────────────────┘
|
||||||
|
|
||||||
|
入场(三重共振):
|
||||||
|
1. MACD 金叉(上穿信号线)
|
||||||
|
2. RSI 在 [30, 65] 区间(合理动量)
|
||||||
|
3. 价格 > 布林中轨(趋势向上)
|
||||||
|
|
||||||
|
出场(任一触发):
|
||||||
|
1. MACD 死叉(下穿信号线)
|
||||||
|
2. RSI > 75(过热)
|
||||||
|
3. 价格 < 布林下轨(趋势破位)
|
||||||
|
"""
|
||||||
|
|
||||||
|
strategy_type = "triple_factor"
|
||||||
|
|
||||||
|
def __init__(self, config: TripleFactorConfig):
|
||||||
|
super().__init__(config)
|
||||||
|
self.cfg: TripleFactorConfig = config
|
||||||
|
self._closes: list[float] = []
|
||||||
|
self._highs: list[float] = []
|
||||||
|
self._lows: list[float] = []
|
||||||
|
|
||||||
|
async def on_kline(self, kline: Kline) -> Optional[Signal]:
|
||||||
|
self._closes.append(kline.close)
|
||||||
|
self._highs.append(kline.high)
|
||||||
|
self._lows.append(kline.low)
|
||||||
|
|
||||||
|
n = len(self._closes)
|
||||||
|
max_period = max(
|
||||||
|
self.cfg.macd_slow + self.cfg.macd_signal,
|
||||||
|
self.cfg.rsi_period + 1,
|
||||||
|
self.cfg.bb_period,
|
||||||
|
)
|
||||||
|
if n < max_period:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── 全量计算因子(每个 bar 一次)──
|
||||||
|
macd_line, signal_line, _hist = macd(
|
||||||
|
self._closes,
|
||||||
|
fast=self.cfg.macd_fast,
|
||||||
|
slow=self.cfg.macd_slow,
|
||||||
|
signal=self.cfg.macd_signal,
|
||||||
|
)
|
||||||
|
rsi_vals = rsi(self._closes, period=self.cfg.rsi_period)
|
||||||
|
_upper, mid, lower = bollinger(
|
||||||
|
self._closes,
|
||||||
|
period=self.cfg.bb_period,
|
||||||
|
std=self.cfg.bb_std,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 当前值和前一根的值
|
||||||
|
cur_macd = macd_line[-1]
|
||||||
|
cur_signal = signal_line[-1]
|
||||||
|
prev_macd = macd_line[-2]
|
||||||
|
prev_signal = signal_line[-2]
|
||||||
|
cur_rsi = rsi_vals[-1]
|
||||||
|
prev_rsi = rsi_vals[-2]
|
||||||
|
cur_mid = mid[-1]
|
||||||
|
cur_lower = lower[-1]
|
||||||
|
cur_price = kline.close
|
||||||
|
|
||||||
|
if cur_macd == 0.0 or cur_rsi == 0.0 or cur_mid == 0.0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── 入场:2/3 共振即可 ──
|
||||||
|
golden_cross = prev_macd <= prev_signal and cur_macd > cur_signal
|
||||||
|
rsi_ok = self.cfg.rsi_oversold < cur_rsi < self.cfg.rsi_overbought
|
||||||
|
above_mid = cur_price > cur_mid
|
||||||
|
|
||||||
|
score = golden_cross + rsi_ok + above_mid
|
||||||
|
if score >= 2:
|
||||||
|
return Signal(
|
||||||
|
symbol=self.cfg.symbol,
|
||||||
|
side="BUY",
|
||||||
|
signal_type="MARKET",
|
||||||
|
confidence=0.6 + score * 0.1,
|
||||||
|
reason=(
|
||||||
|
f"{score}/3共振"
|
||||||
|
f"{' MACD金叉' if golden_cross else ''}"
|
||||||
|
f"{' RSI=' + str(round(cur_rsi, 1)) if rsi_ok else ''}"
|
||||||
|
f"{' Price>BBmid' if above_mid else ''}"
|
||||||
|
),
|
||||||
|
timestamp=kline.open_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 出场:任一强信号 ──
|
||||||
|
death_cross = prev_macd >= prev_signal and cur_macd < cur_signal
|
||||||
|
rsi_overheat = cur_rsi > self.cfg.rsi_exit
|
||||||
|
below_lower = cur_price < cur_lower
|
||||||
|
|
||||||
|
if death_cross:
|
||||||
|
return Signal(
|
||||||
|
symbol=self.cfg.symbol,
|
||||||
|
side="SELL",
|
||||||
|
signal_type="MARKET",
|
||||||
|
confidence=0.8,
|
||||||
|
reason="MACD死叉",
|
||||||
|
timestamp=kline.open_time,
|
||||||
|
)
|
||||||
|
if rsi_overheat and cur_rsi > prev_rsi:
|
||||||
|
return Signal(
|
||||||
|
symbol=self.cfg.symbol,
|
||||||
|
side="SELL",
|
||||||
|
signal_type="MARKET",
|
||||||
|
confidence=0.7,
|
||||||
|
reason=f"RSI过热({cur_rsi:.1f})",
|
||||||
|
timestamp=kline.open_time,
|
||||||
|
)
|
||||||
|
if below_lower and cur_price < cur_mid:
|
||||||
|
return Signal(
|
||||||
|
symbol=self.cfg.symbol,
|
||||||
|
side="SELL",
|
||||||
|
signal_type="MARKET",
|
||||||
|
confidence=0.6,
|
||||||
|
reason=f"跌破BB下轨({cur_lower:.2f})",
|
||||||
|
timestamp=kline.open_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 主函数
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
bt_config = BacktestConfig(
|
||||||
|
symbol="BTCUSDT",
|
||||||
|
interval="4h",
|
||||||
|
start_time=datetime(2024, 1, 1),
|
||||||
|
end_time=datetime(2026, 1, 1),
|
||||||
|
initial_capital=10_000.0,
|
||||||
|
commission_pct=0.001,
|
||||||
|
slippage_pct=0.0005,
|
||||||
|
warmup_bars=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
strategy_config = TripleFactorConfig(
|
||||||
|
name="triple_factor_btc",
|
||||||
|
symbol="BTCUSDT",
|
||||||
|
macd_fast=12,
|
||||||
|
macd_slow=26,
|
||||||
|
macd_signal=9,
|
||||||
|
rsi_period=14,
|
||||||
|
rsi_oversold=30.0,
|
||||||
|
rsi_overbought=65.0,
|
||||||
|
rsi_exit=75.0,
|
||||||
|
bb_period=20,
|
||||||
|
bb_std=2.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("╔" + "═" * 58 + "╗")
|
||||||
|
print("║" + " 多因子组合回测 — 三重共振策略".center(52) + "║")
|
||||||
|
print("╠" + "═" * 58 + "╣")
|
||||||
|
print(f"║ {'交易对:':<8} {bt_config.symbol:<12} {'周期:':<6} {bt_config.interval:<10} ║")
|
||||||
|
print(f"║ {'时间:':<8} {bt_config.start_time.date()} ~ {bt_config.end_time.date()} ║")
|
||||||
|
print(f"║ {'初始资金:':<8} {bt_config.initial_capital:>12.2f} USDT ║")
|
||||||
|
print("╠" + "═" * 58 + "╣")
|
||||||
|
print("║ 因子组合: ║")
|
||||||
|
print(f"║ 1. MACD({strategy_config.macd_fast},{strategy_config.macd_slow},{strategy_config.macd_signal}) — 趋势方向 ║")
|
||||||
|
print(f"║ 2. RSI({strategy_config.rsi_period}) — 动量过滤 ║")
|
||||||
|
print(f"║ 3. Bollinger({strategy_config.bb_period},{strategy_config.bb_std}) — 波动率确认 ║")
|
||||||
|
print("╚" + "═" * 58 + "╝")
|
||||||
|
print()
|
||||||
|
|
||||||
|
engine = BacktestEngine(bt_config, db_config=config.db)
|
||||||
|
result = await engine.run(TripleFactorStrategy, strategy_config)
|
||||||
|
|
||||||
|
print(result.summary())
|
||||||
|
|
||||||
|
# 打印全部交易
|
||||||
|
if result.trades:
|
||||||
|
print(f"\n全部交易 ({len(result.trades)} 笔):")
|
||||||
|
print(f"{'时间':<22} {'方向':<6} {'价格':>10} {'数量':>10} {'盈亏':>10} 原因")
|
||||||
|
print("-" * 100)
|
||||||
|
for t in result.trades:
|
||||||
|
dt = datetime.fromtimestamp(t.timestamp / 1000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
|
||||||
|
pnl_str = f"{t.pnl:+.4f}" if t.pnl is not None else "—"
|
||||||
|
print(f"{dt:<22} {t.side:<6} {t.price:>10.2f} {t.quantity:>10.6f} {pnl_str:>10} {t.reason}")
|
||||||
|
|
||||||
|
print("\n回测完成。")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""
|
||||||
|
全周期回测 — 2017-2026,覆盖完整牛熊
|
||||||
|
|
||||||
|
多空双向 EMA 趋势跟踪,展示牛市/熊市/全周期分段表现。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
source .venv/bin/activate && python example/full_cycle.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
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.indicators import ema, atr
|
||||||
|
from engine.example.long_short import LongShortEngine, LongShortEmaConfig, LongShortEmaStrategy
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
|
||||||
|
|
||||||
|
PARAMS = {
|
||||||
|
"BTCUSDT": (10, 50),
|
||||||
|
"ETHUSDT": (10, 75),
|
||||||
|
"BNBUSDT": (20, 50),
|
||||||
|
"SOLUSDT": (30, 50),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 牛熊分段(以 BTC 为参考)
|
||||||
|
PERIODS = [
|
||||||
|
("2017-2018 牛市", datetime(2017, 1, 1), datetime(2018, 1, 1)),
|
||||||
|
("2018 熊市", datetime(2018, 1, 1), datetime(2019, 1, 1)),
|
||||||
|
("2019 反弹", datetime(2019, 1, 1), datetime(2020, 1, 1)),
|
||||||
|
("2020 牛初+312", datetime(2020, 1, 1), datetime(2021, 1, 1)),
|
||||||
|
("2021 牛市", datetime(2021, 1, 1), datetime(2022, 1, 1)),
|
||||||
|
("2022 熊市", datetime(2022, 1, 1), datetime(2023, 1, 1)),
|
||||||
|
("2023 复苏", datetime(2023, 1, 1), datetime(2024, 1, 1)),
|
||||||
|
("2024-2025 牛市", datetime(2024, 1, 1), datetime(2026, 1, 1)),
|
||||||
|
]
|
||||||
|
|
||||||
|
DATE_START = datetime(2017, 1, 1)
|
||||||
|
DATE_END = datetime(2026, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_backtest(symbol, fast, slow, start, end):
|
||||||
|
sc = LongShortEmaConfig(symbol=symbol, fast=fast, slow=slow)
|
||||||
|
bt = BacktestConfig(symbol=symbol, interval="4h", start_time=start, end_time=end,
|
||||||
|
initial_capital=10_000.0)
|
||||||
|
engine = LongShortEngine(bt, db_config=config.db)
|
||||||
|
return await engine.run(LongShortEmaStrategy, sc)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print()
|
||||||
|
print("═" * 125)
|
||||||
|
print(" 全周期多空回测 — 2017-2026 完整牛熊 | 4h EMA趋势")
|
||||||
|
print("═" * 125)
|
||||||
|
|
||||||
|
# ── 全周期汇总 ──
|
||||||
|
print(f"\n ■ 全周期 2017-2026 汇总")
|
||||||
|
print(f" {'币种':<10} {'总收益%':>7} {'年化%':>7} {'夏普':>6} {'回撤%':>7} {'交易':>5} {'多头P&L':>10} {'空头P&L':>10}")
|
||||||
|
print(" " + "─" * 105)
|
||||||
|
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
fast, slow = PARAMS[symbol]
|
||||||
|
r = await run_backtest(symbol, fast, slow, DATE_START, DATE_END)
|
||||||
|
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} {m.total_return_pct:>6.1f}% {m.annual_return_pct:>6.1f}% {m.sharpe_ratio:>6.2f} {m.max_drawdown_pct:>6.1f}% {m.total_trades:>5} {long_pnl:>+9.0f} {short_pnl:>+9.0f}")
|
||||||
|
|
||||||
|
# ── BTC 分段 ──
|
||||||
|
print(f"\n ■ BTC 各阶段表现 (参数 EMA{10},{50})")
|
||||||
|
print(f" {'阶段':<22} {'总收益%':>7} {'年化%':>7} {'夏普':>6} {'回撤%':>7} {'多头P&L':>10} {'空头P&L':>10}")
|
||||||
|
print(" " + "─" * 105)
|
||||||
|
|
||||||
|
for period_name, p_start, p_end in PERIODS:
|
||||||
|
try:
|
||||||
|
r = await run_backtest("BTCUSDT", 10, 50, p_start, p_end)
|
||||||
|
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" {period_name:<22} {m.total_return_pct:>6.1f}% {m.annual_return_pct:>6.1f}% {m.sharpe_ratio:>6.2f} {m.max_drawdown_pct:>6.1f}% {long_pnl:>+9.0f} {short_pnl:>+9.0f}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" {period_name:<22} 数据不足或错误: {e}")
|
||||||
|
|
||||||
|
# ── 只做多 vs 多空全周期对比 ──
|
||||||
|
print(f"\n ■ BTC 只做多 vs 多空 (全周期)")
|
||||||
|
# 只做多需要单跑一次(LongShortEngine 本身就支持只做多:不开空就行)
|
||||||
|
# 简单做法:用原版 BacktestEngine 跑一次只做多
|
||||||
|
from engine.backtest import BacktestEngine
|
||||||
|
from engine.common.base import BaseStrategy as BS, Signal as Sig, StrategyConfig as SC
|
||||||
|
|
||||||
|
class LongOnlyEMAConfig(SC):
|
||||||
|
fast: int = 10; slow: int = 50; atr_stop: float = 2.5
|
||||||
|
|
||||||
|
class LongOnlyEMAStrategy(BS):
|
||||||
|
strategy_type = "long_only"
|
||||||
|
def __init__(self, c): super().__init__(c); self.cfg = c
|
||||||
|
async def on_start(self): self._c = []; self._h = []; self._l = []; self._hp = 0.0; self._in = False; await super().on_start()
|
||||||
|
async def on_kline(self, k):
|
||||||
|
self._c.append(k.close); self._h.append(k.high); self._l.append(k.low)
|
||||||
|
n = len(self._c)
|
||||||
|
if n < self.cfg.slow + 5: return None
|
||||||
|
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 = f[-1], s[-1], a[-1]; pf, ps = f[-2], s[-2]
|
||||||
|
if cf == 0 or cs == 0 or ca == 0: return None
|
||||||
|
if self._in:
|
||||||
|
self._hp = max(self._hp, k.high); stop = self._hp - self.cfg.atr_stop * ca
|
||||||
|
if (pf >= ps and cf < cs) or k.close < stop:
|
||||||
|
self._in = False
|
||||||
|
return Sig(symbol=self.cfg.symbol, side="SELL", reason="死叉" if pf >= ps else "ATR止损", timestamp=k.open_time)
|
||||||
|
else:
|
||||||
|
if pf <= ps and cf > cs:
|
||||||
|
self._in = True; self._hp = k.close
|
||||||
|
return Sig(symbol=self.cfg.symbol, side="BUY", reason="金叉", timestamp=k.open_time)
|
||||||
|
return None
|
||||||
|
|
||||||
|
lo_sc = LongOnlyEMAConfig(symbol="BTCUSDT")
|
||||||
|
lo_bt = BacktestConfig(symbol="BTCUSDT", interval="4h", start_time=DATE_START, end_time=DATE_END, initial_capital=10_000.0)
|
||||||
|
lo_eng = BacktestEngine(lo_bt, db_config=config.db)
|
||||||
|
lo_r = await lo_eng.run(LongOnlyEMAStrategy, lo_sc)
|
||||||
|
lo_m = lo_r.metrics
|
||||||
|
|
||||||
|
# 多空
|
||||||
|
ls_r = await run_backtest("BTCUSDT", 10, 50, DATE_START, DATE_END)
|
||||||
|
ls_m = ls_r.metrics
|
||||||
|
|
||||||
|
print(f" {'':<10} {'总收益%':>7} {'年化%':>7} {'夏普':>6} {'回撤%':>7} {'交易':>5}")
|
||||||
|
print(f" {'只做多':<10} {lo_m.total_return_pct:>6.1f}% {lo_m.annual_return_pct:>6.1f}% {lo_m.sharpe_ratio:>6.2f} {lo_m.max_drawdown_pct:>6.1f}% {lo_m.total_trades:>5}")
|
||||||
|
print(f" {'多空':<10} {ls_m.total_return_pct:>6.1f}% {ls_m.annual_return_pct:>6.1f}% {ls_m.sharpe_ratio:>6.2f} {ls_m.max_drawdown_pct:>6.1f}% {ls_m.total_trades:>5}")
|
||||||
|
|
||||||
|
print("\n═" * 125)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
"""
|
||||||
|
多空双向回测 — EMA 趋势跟踪(支持做空)
|
||||||
|
|
||||||
|
基于表现最好的纯趋势参数,增加做空能力:
|
||||||
|
- 金叉 → 平空仓 + 做多
|
||||||
|
- 死叉 → 平多仓 + 做空
|
||||||
|
- ATR 动态止损(多空双向)
|
||||||
|
- 始终持仓(非多即空)
|
||||||
|
- 输出增加年化收益
|
||||||
|
|
||||||
|
参数(各币种历史最优):
|
||||||
|
BTC(10,50) ETH(10,75) BNB(20,50) SOL(30,50)
|
||||||
|
|
||||||
|
用法:
|
||||||
|
source .venv/bin/activate && python example/long_short.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import statistics
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, 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, DBConfig
|
||||||
|
from engine.data import DataService
|
||||||
|
from engine.indicators import ema, atr
|
||||||
|
from engine.backtest.models import BacktestConfig, BacktestMetrics, BacktestResult, BacktestTrade
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# 多空回测引擎
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class LongShortEngine:
|
||||||
|
"""支持多空双向的事件驱动回测引擎"""
|
||||||
|
|
||||||
|
def __init__(self, bt_config: BacktestConfig, db_config=None):
|
||||||
|
self.config = bt_config
|
||||||
|
self._db_config = db_config
|
||||||
|
self._cash: float = bt_config.initial_capital
|
||||||
|
self._position: float = 0.0 # >0 多头, <0 空头, =0 空仓
|
||||||
|
self._avg_entry_price: float = 0.0
|
||||||
|
self._trades: list[BacktestTrade] = []
|
||||||
|
self._equity: list[dict] = []
|
||||||
|
self._peak_equity: float = 0.0
|
||||||
|
self._pending_buy: Optional[Signal] = None
|
||||||
|
self._pending_sell: Optional[Signal] = None
|
||||||
|
|
||||||
|
async def run(self, strategy_cls, strategy_config: StrategyConfig) -> BacktestResult:
|
||||||
|
from engine.common.config import config as app_config
|
||||||
|
strategy_config.symbol = self.config.symbol
|
||||||
|
strategy_config.exchange = self.config.exchange
|
||||||
|
db_cfg = self._db_config or app_config.db
|
||||||
|
ds = DataService(db_cfg)
|
||||||
|
await ds.connect()
|
||||||
|
try:
|
||||||
|
klines = await ds.fetch_klines(
|
||||||
|
symbol=self.config.symbol, interval=self.config.interval,
|
||||||
|
start_time=self.config.start_time, end_time=self.config.end_time,
|
||||||
|
limit=1_000_000,
|
||||||
|
)
|
||||||
|
if len(klines) < self.config.warmup_bars + 2:
|
||||||
|
raise ValueError(f"数据不足:需 {self.config.warmup_bars+2},实际 {len(klines)}")
|
||||||
|
|
||||||
|
strategy = strategy_cls(strategy_config)
|
||||||
|
await strategy.on_start()
|
||||||
|
self._cash = self.config.initial_capital
|
||||||
|
self._position = 0.0
|
||||||
|
self._avg_entry_price = 0.0
|
||||||
|
self._trades = []
|
||||||
|
self._equity = []
|
||||||
|
self._pending_buy = None
|
||||||
|
self._pending_sell = None
|
||||||
|
|
||||||
|
warmup_end = self.config.warmup_bars
|
||||||
|
for i in range(warmup_end):
|
||||||
|
await strategy.on_kline(klines[i])
|
||||||
|
|
||||||
|
for i in range(warmup_end, len(klines)):
|
||||||
|
kline = klines[i]
|
||||||
|
|
||||||
|
# 先执行待执行订单(下一根 bar 开盘价)
|
||||||
|
if self._pending_buy is not None:
|
||||||
|
self._execute_buy(self._pending_buy, kline)
|
||||||
|
self._pending_buy = None
|
||||||
|
if self._pending_sell is not None:
|
||||||
|
self._execute_sell(self._pending_sell, kline)
|
||||||
|
self._pending_sell = None
|
||||||
|
|
||||||
|
signal = await strategy.on_kline(kline)
|
||||||
|
|
||||||
|
if signal is not None and signal.side == "BUY":
|
||||||
|
self._pending_buy = signal
|
||||||
|
elif signal is not None and signal.side == "SELL":
|
||||||
|
self._pending_sell = signal
|
||||||
|
|
||||||
|
self._record_equity(kline)
|
||||||
|
|
||||||
|
# 强平
|
||||||
|
if self._position != 0 and len(klines) > 0:
|
||||||
|
last_k = klines[-1]
|
||||||
|
if self._position > 0:
|
||||||
|
self._execute_sell(Signal(symbol=self.config.symbol, side="SELL",
|
||||||
|
quantity=abs(self._position),
|
||||||
|
reason="回测结束—强平多仓", timestamp=last_k.open_time), last_k)
|
||||||
|
else:
|
||||||
|
self._execute_buy(Signal(symbol=self.config.symbol, side="BUY",
|
||||||
|
quantity=abs(self._position),
|
||||||
|
reason="回测结束—强平空仓", timestamp=last_k.open_time), last_k)
|
||||||
|
|
||||||
|
await strategy.on_stop()
|
||||||
|
metrics = self._compute_metrics()
|
||||||
|
return BacktestResult(config=self.config, strategy_config=strategy_config.model_dump(),
|
||||||
|
metrics=metrics, trades=self._trades, equity_curve=self._equity)
|
||||||
|
finally:
|
||||||
|
await ds.close()
|
||||||
|
|
||||||
|
# ── 交易执行 ──
|
||||||
|
|
||||||
|
def _execute_buy(self, signal: Signal, kline: Kline) -> None:
|
||||||
|
exec_price = kline.open * (1 + self.config.slippage_pct)
|
||||||
|
qty = signal.quantity
|
||||||
|
if qty is None:
|
||||||
|
if self._position < 0:
|
||||||
|
qty = abs(self._position) # 平空仓
|
||||||
|
else:
|
||||||
|
max_notional = self._cash * signal.confidence
|
||||||
|
qty = max_notional / exec_price
|
||||||
|
qty = self._round_qty(qty)
|
||||||
|
if qty < self.config.min_order_qty:
|
||||||
|
return
|
||||||
|
|
||||||
|
notional = exec_price * qty
|
||||||
|
commission = notional * self.config.commission_pct
|
||||||
|
|
||||||
|
if self._position < 0:
|
||||||
|
# 平空仓
|
||||||
|
cover_qty = min(qty, abs(self._position))
|
||||||
|
cover_notional = exec_price * cover_qty
|
||||||
|
cover_comm = cover_notional * self.config.commission_pct
|
||||||
|
pnl = (self._avg_entry_price - exec_price) * cover_qty - cover_comm
|
||||||
|
self._cash -= cover_notional + cover_comm
|
||||||
|
self._position += cover_qty
|
||||||
|
if abs(self._position) < self.config.min_order_qty:
|
||||||
|
self._position = 0.0
|
||||||
|
self._avg_entry_price = 0.0
|
||||||
|
self._trades.append(BacktestTrade(timestamp=kline.open_time, symbol=self.config.symbol,
|
||||||
|
side="BUY", price=exec_price, quantity=cover_qty,
|
||||||
|
notional=cover_notional, commission=cover_comm,
|
||||||
|
slippage=exec_price - kline.open, pnl=pnl,
|
||||||
|
reason=signal.reason))
|
||||||
|
|
||||||
|
# 剩余开多
|
||||||
|
remaining = qty - cover_qty
|
||||||
|
if remaining >= self.config.min_order_qty:
|
||||||
|
self._open_long(remaining, exec_price, kline, signal)
|
||||||
|
else:
|
||||||
|
# 开多 / 加仓
|
||||||
|
self._open_long(qty, exec_price, kline, signal)
|
||||||
|
|
||||||
|
def _open_long(self, qty: float, exec_price: float, kline: Kline, signal: Signal):
|
||||||
|
notional = exec_price * qty
|
||||||
|
commission = notional * self.config.commission_pct
|
||||||
|
total_cost = notional + commission
|
||||||
|
if total_cost > self._cash:
|
||||||
|
qty = self._round_qty(self._cash / (exec_price * (1 + self.config.commission_pct)))
|
||||||
|
if qty < self.config.min_order_qty:
|
||||||
|
return
|
||||||
|
notional = exec_price * qty
|
||||||
|
commission = notional * self.config.commission_pct
|
||||||
|
total_cost = notional + commission
|
||||||
|
if self._position > 0:
|
||||||
|
total_value = self._avg_entry_price * self._position + notional
|
||||||
|
self._position += qty
|
||||||
|
self._avg_entry_price = total_value / self._position
|
||||||
|
else:
|
||||||
|
self._position = qty
|
||||||
|
self._avg_entry_price = exec_price
|
||||||
|
self._cash -= total_cost
|
||||||
|
self._trades.append(BacktestTrade(timestamp=kline.open_time, symbol=self.config.symbol,
|
||||||
|
side="BUY", price=exec_price, quantity=qty,
|
||||||
|
notional=notional, commission=commission,
|
||||||
|
slippage=exec_price - kline.open, reason=signal.reason))
|
||||||
|
|
||||||
|
def _execute_sell(self, signal: Signal, kline: Kline) -> None:
|
||||||
|
exec_price = kline.close * (1 - self.config.slippage_pct)
|
||||||
|
qty = signal.quantity
|
||||||
|
if qty is None:
|
||||||
|
if self._position > 0:
|
||||||
|
qty = self._position
|
||||||
|
else:
|
||||||
|
max_notional = self._cash * signal.confidence
|
||||||
|
qty = max_notional / exec_price
|
||||||
|
qty = self._round_qty(qty)
|
||||||
|
if qty < self.config.min_order_qty:
|
||||||
|
return
|
||||||
|
|
||||||
|
notional = exec_price * qty
|
||||||
|
commission = notional * self.config.commission_pct
|
||||||
|
|
||||||
|
if self._position > 0:
|
||||||
|
# 平多仓
|
||||||
|
close_qty = min(qty, self._position)
|
||||||
|
close_notional = exec_price * close_qty
|
||||||
|
close_comm = close_notional * self.config.commission_pct
|
||||||
|
pnl = (exec_price - self._avg_entry_price) * close_qty - close_comm
|
||||||
|
self._position -= close_qty
|
||||||
|
self._cash += close_notional - close_comm
|
||||||
|
if self._position < self.config.min_order_qty:
|
||||||
|
self._position = 0.0
|
||||||
|
self._avg_entry_price = 0.0
|
||||||
|
self._trades.append(BacktestTrade(timestamp=kline.open_time, symbol=self.config.symbol,
|
||||||
|
side="SELL", price=exec_price, quantity=close_qty,
|
||||||
|
notional=close_notional, commission=close_comm,
|
||||||
|
slippage=kline.close - exec_price, pnl=pnl,
|
||||||
|
reason=signal.reason))
|
||||||
|
# 剩余开空
|
||||||
|
remaining = qty - close_qty
|
||||||
|
if remaining >= self.config.min_order_qty:
|
||||||
|
self._open_short(remaining, exec_price, kline, signal)
|
||||||
|
else:
|
||||||
|
self._open_short(qty, exec_price, kline, signal)
|
||||||
|
|
||||||
|
def _open_short(self, qty: float, exec_price: float, kline: Kline, signal: Signal):
|
||||||
|
notional = exec_price * qty
|
||||||
|
commission = notional * self.config.commission_pct
|
||||||
|
if self._position < 0:
|
||||||
|
total_value = self._avg_entry_price * abs(self._position) + notional
|
||||||
|
self._position -= qty
|
||||||
|
self._avg_entry_price = total_value / abs(self._position)
|
||||||
|
else:
|
||||||
|
self._position = -qty
|
||||||
|
self._avg_entry_price = exec_price
|
||||||
|
self._cash += notional - commission
|
||||||
|
self._trades.append(BacktestTrade(timestamp=kline.open_time, symbol=self.config.symbol,
|
||||||
|
side="SELL", price=exec_price, quantity=qty,
|
||||||
|
notional=notional, commission=commission,
|
||||||
|
slippage=kline.close - exec_price, reason=signal.reason))
|
||||||
|
|
||||||
|
# ── 资金曲线 ──
|
||||||
|
|
||||||
|
def _record_equity(self, kline: Kline) -> None:
|
||||||
|
equity = self._cash + self._position * kline.close
|
||||||
|
if not self._equity:
|
||||||
|
self._peak_equity = equity
|
||||||
|
elif equity > self._peak_equity:
|
||||||
|
self._peak_equity = equity
|
||||||
|
dd = (equity - self._peak_equity) / self._peak_equity * 100 if self._peak_equity > 0 else 0.0
|
||||||
|
self._equity.append({"timestamp": kline.open_time, "equity": equity,
|
||||||
|
"drawdown": dd, "position": self._position})
|
||||||
|
|
||||||
|
# ── 绩效 ──
|
||||||
|
|
||||||
|
def _compute_metrics(self) -> BacktestMetrics:
|
||||||
|
if not self._equity:
|
||||||
|
return BacktestMetrics()
|
||||||
|
initial = self.config.initial_capital
|
||||||
|
final = self._equity[-1]["equity"]
|
||||||
|
total_return_pct = (final - initial) / initial * 100
|
||||||
|
|
||||||
|
first_ts = self._equity[0]["timestamp"]
|
||||||
|
last_ts = self._equity[-1]["timestamp"]
|
||||||
|
days = (last_ts - first_ts) / (1000 * 86400)
|
||||||
|
if days > 0 and final > 0 and initial > 0:
|
||||||
|
annual_return_pct = ((final / initial) ** (365 / days) - 1) * 100
|
||||||
|
else:
|
||||||
|
annual_return_pct = 0.0
|
||||||
|
|
||||||
|
daily_returns = self._compute_daily_returns()
|
||||||
|
if len(daily_returns) > 1:
|
||||||
|
mean_ret = statistics.mean(daily_returns)
|
||||||
|
std_ret = statistics.stdev(daily_returns)
|
||||||
|
sharpe_ratio = (mean_ret / std_ret * (365 ** 0.5)) if std_ret > 0 else 0.0
|
||||||
|
else:
|
||||||
|
sharpe_ratio = 0.0
|
||||||
|
|
||||||
|
max_dd_pct, max_dd_days = self._compute_max_drawdown()
|
||||||
|
closed = [t for t in self._trades if t.pnl is not None]
|
||||||
|
total_trades = len(closed)
|
||||||
|
if total_trades > 0:
|
||||||
|
winners = [t for t in closed if t.pnl > 0]
|
||||||
|
losers = [t for t in closed if t.pnl <= 0]
|
||||||
|
win_rate = len(winners) / total_trades
|
||||||
|
gp = sum(t.pnl for t in winners)
|
||||||
|
gl = abs(sum(t.pnl for t in losers))
|
||||||
|
profit_factor = gp / gl if gl > 0 else (gp if gp > 0 else 0.0)
|
||||||
|
avg_pnl = sum(t.pnl for t in closed) / total_trades
|
||||||
|
best_pnl = max(t.pnl for t in closed)
|
||||||
|
worst_pnl = min(t.pnl for t in closed)
|
||||||
|
else:
|
||||||
|
win_rate = profit_factor = avg_pnl = best_pnl = worst_pnl = 0.0
|
||||||
|
|
||||||
|
calmar = annual_return_pct / abs(max_dd_pct) if max_dd_pct < 0 else 0.0
|
||||||
|
return BacktestMetrics(
|
||||||
|
total_return_pct=total_return_pct, annual_return_pct=annual_return_pct,
|
||||||
|
sharpe_ratio=sharpe_ratio, max_drawdown_pct=max_dd_pct,
|
||||||
|
max_drawdown_duration_days=max_dd_days, win_rate=win_rate,
|
||||||
|
profit_factor=profit_factor, total_trades=total_trades,
|
||||||
|
avg_trade_pnl=avg_pnl, best_trade_pnl=best_pnl, worst_trade_pnl=worst_pnl,
|
||||||
|
calmar_ratio=calmar, final_equity=final,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _compute_daily_returns(self) -> list[float]:
|
||||||
|
if not self._equity:
|
||||||
|
return []
|
||||||
|
daily: dict[str, float] = {}
|
||||||
|
for point in self._equity:
|
||||||
|
dt = datetime.fromtimestamp(point["timestamp"] / 1000, tz=timezone.utc)
|
||||||
|
daily[dt.strftime("%Y-%m-%d")] = point["equity"]
|
||||||
|
sorted_dates = sorted(daily.keys())
|
||||||
|
returns = []
|
||||||
|
for i in range(1, len(sorted_dates)):
|
||||||
|
prev = daily[sorted_dates[i - 1]]
|
||||||
|
curr = daily[sorted_dates[i]]
|
||||||
|
if prev > 0:
|
||||||
|
returns.append((curr - prev) / prev)
|
||||||
|
return returns
|
||||||
|
|
||||||
|
def _compute_max_drawdown(self) -> tuple[float, int]:
|
||||||
|
if not self._equity:
|
||||||
|
return 0.0, 0
|
||||||
|
peak = self._equity[0]["equity"]
|
||||||
|
max_dd = 0.0
|
||||||
|
dd_start_idx = 0
|
||||||
|
max_dd_days = 0
|
||||||
|
for i, point in enumerate(self._equity):
|
||||||
|
equity = point["equity"]
|
||||||
|
if equity > peak:
|
||||||
|
peak = equity
|
||||||
|
dd_start_idx = i
|
||||||
|
dd = (equity - peak) / peak * 100
|
||||||
|
if dd < max_dd:
|
||||||
|
max_dd = dd
|
||||||
|
peak_ts = self._equity[dd_start_idx]["timestamp"]
|
||||||
|
dd_days = int((point["timestamp"] - peak_ts) / (1000 * 86400))
|
||||||
|
if dd_days > max_dd_days:
|
||||||
|
max_dd_days = dd_days
|
||||||
|
return max_dd, max_dd_days
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _round_qty(qty: float, decimals: int = 8) -> float:
|
||||||
|
factor = 10 ** decimals
|
||||||
|
return int(qty * factor) / factor
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# 多空趋势策略
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class LongShortEmaConfig(StrategyConfig):
|
||||||
|
fast: int = 10
|
||||||
|
slow: int = 50
|
||||||
|
atr_stop: float = 2.5
|
||||||
|
|
||||||
|
|
||||||
|
class LongShortEmaStrategy(BaseStrategy):
|
||||||
|
"""EMA金叉做多、死叉做空,始终在场"""
|
||||||
|
|
||||||
|
strategy_type = "long_short_ema"
|
||||||
|
|
||||||
|
def __init__(self, c: LongShortEmaConfig):
|
||||||
|
super().__init__(c)
|
||||||
|
self.cfg = c
|
||||||
|
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 = "" # "long" / "short"
|
||||||
|
|
||||||
|
async def on_kline(self, k: Kline) -> Optional[Signal]:
|
||||||
|
self._closes.append(k.close)
|
||||||
|
self._highs.append(k.high)
|
||||||
|
self._lows.append(k.low)
|
||||||
|
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]
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
|
||||||
|
|
||||||
|
# 各币种历史最优参数
|
||||||
|
PARAMS = {
|
||||||
|
"BTCUSDT": (10, 50),
|
||||||
|
"ETHUSDT": (10, 75),
|
||||||
|
"BNBUSDT": (20, 50),
|
||||||
|
"SOLUSDT": (30, 50),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 只做多结果(用于对比)
|
||||||
|
LONG_ONLY = {
|
||||||
|
"BTCUSDT": (39.9, 1.03, -11.5, 18.3),
|
||||||
|
"ETHUSDT": (53.6, 1.04, -15.3, 23.9),
|
||||||
|
"BNBUSDT": (52.0, 0.71, -39.8, 23.3),
|
||||||
|
"SOLUSDT": (73.6, 1.18, -25.7, 31.7),
|
||||||
|
}
|
||||||
|
# (总收益%, 夏普, 回撤%, 年化%)
|
||||||
|
|
||||||
|
DATE_START = datetime(2024, 1, 1)
|
||||||
|
DATE_END = datetime(2026, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print()
|
||||||
|
print("═" * 112)
|
||||||
|
print(" 多空双向 EMA 趋势跟踪 | 4h | 2024-2026")
|
||||||
|
print("═" * 112)
|
||||||
|
header = f" {'币种':<10} {'方向':<6} {'总收益%':>7} {'年化%':>7} {'夏普':>6} {'回撤%':>7} {'交易':>5} {'胜率%':>6} {'盈亏比':>6}"
|
||||||
|
print(header)
|
||||||
|
print("─" * 112)
|
||||||
|
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
fast, slow = PARAMS[symbol]
|
||||||
|
sc = LongShortEmaConfig(symbol=symbol, fast=fast, slow=slow)
|
||||||
|
bt = BacktestConfig(symbol=symbol, interval="4h",
|
||||||
|
start_time=DATE_START, end_time=DATE_END, initial_capital=10_000.0)
|
||||||
|
engine = LongShortEngine(bt, db_config=config.db)
|
||||||
|
r = await engine.run(LongShortEmaStrategy, sc)
|
||||||
|
m = r.metrics
|
||||||
|
|
||||||
|
long_trades = [t for t in r.trades if t.pnl is not None and t.side == "SELL"]
|
||||||
|
short_trades = [t for t in r.trades if t.pnl is not None and t.side == "BUY"]
|
||||||
|
lo = LONG_ONLY[symbol]
|
||||||
|
|
||||||
|
long_pnl = sum(t.pnl for t in long_trades) if long_trades else 0
|
||||||
|
short_pnl = sum(t.pnl for t in short_trades) if short_trades else 0
|
||||||
|
|
||||||
|
print(f" {symbol:<10} 多空 {m.total_return_pct:>6.1f}% {m.annual_return_pct:>6.1f}% {m.sharpe_ratio:>6.2f} {m.max_drawdown_pct:>6.1f}% {m.total_trades:>5} {m.win_rate*100:>5.1f}% {m.profit_factor:>6.2f}")
|
||||||
|
print(f" {'':<10} 只做多 {lo[0]:>6.1f}% {lo[3]:>6.1f}% {lo[1]:>6.2f} {lo[2]:>6.1f}%")
|
||||||
|
if long_trades or short_trades:
|
||||||
|
print(f" {'':<10} └ 多头P&L {long_pnl:>+7.0f} ({len(long_trades)}笔) 空头P&L {short_pnl:>+7.0f} ({len(short_trades)}笔)")
|
||||||
|
for t in (r.trades[-2:] if r.trades else []):
|
||||||
|
if t.pnl is not None:
|
||||||
|
side_label = "平多" if t.side == "SELL" else "平空"
|
||||||
|
dt = datetime.fromtimestamp(t.timestamp / 1000, tz=timezone.utc).strftime("%m-%d %H:%M")
|
||||||
|
print(f" {'':<10} └ {dt} {side_label} {t.pnl:>+8.2f} {t.reason}")
|
||||||
|
|
||||||
|
print("─" * 112)
|
||||||
|
print("\n═" * 112)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"""
|
||||||
|
多周期策略回测 — 4h 定趋势,30m 找买点
|
||||||
|
|
||||||
|
策略逻辑:
|
||||||
|
1. 4h EMA20 判断大趋势:价格 > EMA20 = 上升趋势
|
||||||
|
2. 30m RSI 寻找入场时机:上升趋势中 RSI < 35 = 回调买入
|
||||||
|
3. 出场:RSI > 70(超买)或 4h 趋势反转向下
|
||||||
|
|
||||||
|
用法:
|
||||||
|
source .venv/bin/activate && python example/multi_tf_demo.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
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, DBConfig
|
||||||
|
from engine.backtest import BacktestEngine, BacktestConfig
|
||||||
|
from engine.data import DataService
|
||||||
|
from engine.indicators import ema, rsi
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 多周期趋势回调策略
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class MultiTFConfig(StrategyConfig):
|
||||||
|
"""多周期策略配置"""
|
||||||
|
|
||||||
|
# 4h 趋势参数
|
||||||
|
trend_ema_period: int = 20
|
||||||
|
|
||||||
|
# 30m 入场参数
|
||||||
|
entry_rsi_period: int = 14
|
||||||
|
entry_rsi_threshold: float = 35.0 # RSI 低于此值视为回调
|
||||||
|
|
||||||
|
# 出场参数
|
||||||
|
exit_rsi_threshold: float = 70.0 # RSI 高于此值出场
|
||||||
|
|
||||||
|
# 数据范围(用于预加载 4h 数据)
|
||||||
|
data_start: Optional[datetime] = None
|
||||||
|
data_end: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MultiTimeframeStrategy(BaseStrategy):
|
||||||
|
"""多周期趋势回调策略
|
||||||
|
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ 4h K 线 → EMA20 判断趋势 │
|
||||||
|
│ Price > EMA20 = 上升趋势 │
|
||||||
|
└─────────────┬───────────────┘
|
||||||
|
│ 上升趋势
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ 30m K 线 → 寻找入场时机 │
|
||||||
|
│ RSI < 35 = 回调买入 │
|
||||||
|
└─────────────┬───────────────┘
|
||||||
|
│ 持仓中
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ 出场条件 │
|
||||||
|
│ RSI > 70 或 4h 趋势反转 │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
"""
|
||||||
|
|
||||||
|
strategy_type = "multi_tf"
|
||||||
|
|
||||||
|
def __init__(self, config: MultiTFConfig):
|
||||||
|
super().__init__(config)
|
||||||
|
self.cfg: MultiTFConfig = config
|
||||||
|
|
||||||
|
# 4h 数据(在 on_start 中加载)
|
||||||
|
self._klines_4h: list[Kline] = []
|
||||||
|
self._ema_4h: list[float] = []
|
||||||
|
|
||||||
|
# 30m 数据积累
|
||||||
|
self._closes_30m: list[float] = []
|
||||||
|
|
||||||
|
# 持仓状态
|
||||||
|
self._has_position: bool = False
|
||||||
|
|
||||||
|
async def on_start(self) -> None:
|
||||||
|
"""预加载 4h K 线数据并计算 EMA"""
|
||||||
|
from engine.common.config import config as app_config
|
||||||
|
|
||||||
|
ds = DataService(app_config.db)
|
||||||
|
await ds.connect()
|
||||||
|
try:
|
||||||
|
self._klines_4h = await ds.fetch_klines(
|
||||||
|
symbol=self.cfg.symbol,
|
||||||
|
interval="4h",
|
||||||
|
start_time=self.cfg.data_start,
|
||||||
|
end_time=self.cfg.data_end,
|
||||||
|
limit=1_000_000,
|
||||||
|
)
|
||||||
|
closes_4h = [k.close for k in self._klines_4h]
|
||||||
|
self._ema_4h = ema(closes_4h, self.cfg.trend_ema_period)
|
||||||
|
finally:
|
||||||
|
await ds.close()
|
||||||
|
|
||||||
|
await super().on_start()
|
||||||
|
|
||||||
|
def _get_4h_trend(self, ts: float) -> tuple[bool, float, float]:
|
||||||
|
"""获取指定时间戳对应的 4h 趋势
|
||||||
|
|
||||||
|
只使用已完成的 4h K 线(close_time <= ts),避免前视偏差。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(is_uptrend, price, ema_value)
|
||||||
|
"""
|
||||||
|
if not self._klines_4h:
|
||||||
|
return False, 0.0, 0.0
|
||||||
|
|
||||||
|
# 从后往前找最近已完成的 4h bar
|
||||||
|
for i in range(len(self._klines_4h) - 1, -1, -1):
|
||||||
|
if self._klines_4h[i].close_time <= ts:
|
||||||
|
price = self._klines_4h[i].close
|
||||||
|
ema_val = self._ema_4h[i]
|
||||||
|
if ema_val == 0.0:
|
||||||
|
return False, price, ema_val
|
||||||
|
return price > ema_val, price, ema_val
|
||||||
|
|
||||||
|
return False, 0.0, 0.0
|
||||||
|
|
||||||
|
async def on_kline(self, kline: Kline) -> Optional[Signal]:
|
||||||
|
self._closes_30m.append(kline.close)
|
||||||
|
|
||||||
|
# ── 获取 4h 趋势 ──
|
||||||
|
is_uptrend, price_4h, ema_4h = self._get_4h_trend(kline.open_time)
|
||||||
|
|
||||||
|
# ── 计算 30m RSI ──
|
||||||
|
rsi_vals = rsi(self._closes_30m, self.cfg.entry_rsi_period)
|
||||||
|
cur_rsi = rsi_vals[-1]
|
||||||
|
|
||||||
|
if cur_rsi == 0.0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── 出场逻辑 ──
|
||||||
|
if self._has_position:
|
||||||
|
# 4h 趋势反转(价格跌破 EMA)→ 止损出场
|
||||||
|
if not is_uptrend and price_4h > 0:
|
||||||
|
self._has_position = False
|
||||||
|
return Signal(
|
||||||
|
symbol=self.cfg.symbol,
|
||||||
|
side="SELL",
|
||||||
|
signal_type="MARKET",
|
||||||
|
confidence=0.9,
|
||||||
|
reason=f"4h趋势反转 Price={price_4h:.2f}<EMA={ema_4h:.2f}",
|
||||||
|
timestamp=kline.open_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 30m RSI 过热 → 止盈出场
|
||||||
|
if cur_rsi > self.cfg.exit_rsi_threshold:
|
||||||
|
self._has_position = False
|
||||||
|
return Signal(
|
||||||
|
symbol=self.cfg.symbol,
|
||||||
|
side="SELL",
|
||||||
|
signal_type="MARKET",
|
||||||
|
confidence=0.8,
|
||||||
|
reason=f"30m RSI过热 {cur_rsi:.1f}>{self.cfg.exit_rsi_threshold}",
|
||||||
|
timestamp=kline.open_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 入场逻辑 ──
|
||||||
|
if not self._has_position:
|
||||||
|
# 条件1:4h 上升趋势
|
||||||
|
if not is_uptrend:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 条件2:30m RSI 回调到超卖区
|
||||||
|
if cur_rsi < self.cfg.entry_rsi_threshold:
|
||||||
|
self._has_position = True
|
||||||
|
return Signal(
|
||||||
|
symbol=self.cfg.symbol,
|
||||||
|
side="BUY",
|
||||||
|
signal_type="MARKET",
|
||||||
|
confidence=0.7,
|
||||||
|
reason=(
|
||||||
|
f"4h升势回调买入 | "
|
||||||
|
f"4hPrice={price_4h:.0f}>EMA={ema_4h:.0f} | "
|
||||||
|
f"30mRSI={cur_rsi:.1f}"
|
||||||
|
),
|
||||||
|
timestamp=kline.open_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 主函数
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
bt_config = BacktestConfig(
|
||||||
|
symbol="ETHUSDT",
|
||||||
|
interval="30m",
|
||||||
|
start_time=datetime(2024, 1, 1),
|
||||||
|
end_time=datetime(2026, 1, 1),
|
||||||
|
initial_capital=10_000.0,
|
||||||
|
commission_pct=0.001,
|
||||||
|
slippage_pct=0.0005,
|
||||||
|
warmup_bars=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
strategy_config = MultiTFConfig(
|
||||||
|
name="multi_tf_eth",
|
||||||
|
symbol="ETHUSDT",
|
||||||
|
trend_ema_period=20,
|
||||||
|
entry_rsi_period=14,
|
||||||
|
entry_rsi_threshold=35.0,
|
||||||
|
exit_rsi_threshold=70.0,
|
||||||
|
data_start=bt_config.start_time,
|
||||||
|
data_end=bt_config.end_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("╔" + "═" * 60 + "╗")
|
||||||
|
print("║" + " 多周期策略 — 4h 定趋势 / 30m 找买点".center(54) + "║")
|
||||||
|
print("╠" + "═" * 60 + "╣")
|
||||||
|
print(f"║ {'交易对:':<8} {bt_config.symbol:<14} {'周期:':<6} {bt_config.interval:<12} ║")
|
||||||
|
print(f"║ {'时间:':<8} {bt_config.start_time.date()} ~ {bt_config.end_time.date()} ║")
|
||||||
|
print("╠" + "═" * 60 + "╣")
|
||||||
|
print("║ 策略逻辑: ║")
|
||||||
|
print(f"║ 4h EMA{strategy_config.trend_ema_period} → 判断趋势方向 ║")
|
||||||
|
print(f"║ 30m RSI{strategy_config.entry_rsi_period} < {strategy_config.entry_rsi_threshold} → 回调买入 ║")
|
||||||
|
print(f"║ 30m RSI{strategy_config.entry_rsi_period} > {strategy_config.exit_rsi_threshold} → 止盈 / 4h趋势反转 → 止损 ║")
|
||||||
|
print("╚" + "═" * 60 + "╝")
|
||||||
|
print()
|
||||||
|
|
||||||
|
engine = BacktestEngine(bt_config, db_config=config.db)
|
||||||
|
result = await engine.run(MultiTimeframeStrategy, strategy_config)
|
||||||
|
|
||||||
|
print(result.summary())
|
||||||
|
|
||||||
|
# 打印最近交易
|
||||||
|
sells = [t for t in result.trades if t.pnl is not None]
|
||||||
|
if sells:
|
||||||
|
print(f"\n最近 10 笔平仓交易:")
|
||||||
|
print(f"{'时间':<22} {'方向':<6} {'价格':>10} {'盈亏':>10} 原因")
|
||||||
|
print("-" * 85)
|
||||||
|
for t in sells[-10:]:
|
||||||
|
dt = datetime.fromtimestamp(t.timestamp / 1000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
|
||||||
|
print(f"{dt:<22} {t.side:<6} {t.price:>10.2f} {t.pnl:>+10.2f} {t.reason}")
|
||||||
|
|
||||||
|
print("\n回测完成。")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"""
|
||||||
|
多周期策略 v2 — 双周期同指标(EMA)
|
||||||
|
|
||||||
|
策略逻辑:
|
||||||
|
4h 和 30m 使用同一个技术指标 EMA,不同参数:
|
||||||
|
- 4h EMA50 → 判断主趋势方向
|
||||||
|
- 30m EMA20 → 寻找入场/出场时机
|
||||||
|
|
||||||
|
入场:4h 多头(Price > EMA50)+ 30m 价格上穿 EMA20
|
||||||
|
出场:30m 价格下穿 EMA20 或 4h 趋势转空
|
||||||
|
|
||||||
|
用法:
|
||||||
|
source .venv/bin/activate && python example/multi_tf_demo2.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
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
|
||||||
|
from engine.data import DataService
|
||||||
|
from engine.indicators import ema
|
||||||
|
|
||||||
|
|
||||||
|
class DualEMATFConfig(StrategyConfig):
|
||||||
|
"""双周期 EMA 策略配置"""
|
||||||
|
|
||||||
|
# 4h 主趋势
|
||||||
|
trend_ema_period: int = 50
|
||||||
|
|
||||||
|
# 30m 交易信号
|
||||||
|
entry_ema_period: int = 20
|
||||||
|
|
||||||
|
# 数据范围
|
||||||
|
data_start: Optional[datetime] = None
|
||||||
|
data_end: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DualEMATFStrategy(BaseStrategy):
|
||||||
|
"""双周期 EMA 均线策略
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ 4h EMA50 → 价格在上=多头趋势 │
|
||||||
|
│ 30m EMA20 → 价格在上+EMA上行+收阳=买入 │
|
||||||
|
│ EMA下行+收阴=卖出 │
|
||||||
|
└──────────────────────────────────────────────┘
|
||||||
|
"""
|
||||||
|
|
||||||
|
strategy_type = "dual_ema_tf"
|
||||||
|
|
||||||
|
def __init__(self, config: DualEMATFConfig):
|
||||||
|
super().__init__(config)
|
||||||
|
self.cfg: DualEMATFConfig = config
|
||||||
|
|
||||||
|
# 4h 预加载数据
|
||||||
|
self._klines_4h: list[Kline] = []
|
||||||
|
self._ema_4h: list[float] = []
|
||||||
|
|
||||||
|
# 30m 数据积累
|
||||||
|
self._closes_30m: list[float] = []
|
||||||
|
self._ema_30m: list[float] = []
|
||||||
|
|
||||||
|
# 持仓
|
||||||
|
self._has_position: bool = False
|
||||||
|
|
||||||
|
async def on_start(self) -> None:
|
||||||
|
"""预加载 4h 数据并计算 EMA50"""
|
||||||
|
from engine.common.config import config as app_config
|
||||||
|
|
||||||
|
ds = DataService(app_config.db)
|
||||||
|
await ds.connect()
|
||||||
|
try:
|
||||||
|
self._klines_4h = await ds.fetch_klines(
|
||||||
|
symbol=self.cfg.symbol,
|
||||||
|
interval="4h",
|
||||||
|
start_time=self.cfg.data_start,
|
||||||
|
end_time=self.cfg.data_end,
|
||||||
|
limit=1_000_000,
|
||||||
|
)
|
||||||
|
closes_4h = [k.close for k in self._klines_4h]
|
||||||
|
self._ema_4h = ema(closes_4h, self.cfg.trend_ema_period)
|
||||||
|
finally:
|
||||||
|
await ds.close()
|
||||||
|
|
||||||
|
await super().on_start()
|
||||||
|
|
||||||
|
def _get_4h_trend(self, ts: float) -> tuple[bool, float, float]:
|
||||||
|
"""4h 趋势判断(仅用已完成 K 线,close_time <= ts)"""
|
||||||
|
if not self._klines_4h:
|
||||||
|
return False, 0.0, 0.0
|
||||||
|
|
||||||
|
for i in range(len(self._klines_4h) - 1, -1, -1):
|
||||||
|
if self._klines_4h[i].close_time <= ts:
|
||||||
|
price = self._klines_4h[i].close
|
||||||
|
ema_val = self._ema_4h[i]
|
||||||
|
if ema_val == 0.0:
|
||||||
|
return False, price, ema_val
|
||||||
|
return price > ema_val, price, ema_val
|
||||||
|
|
||||||
|
return False, 0.0, 0.0
|
||||||
|
|
||||||
|
async def on_kline(self, kline: Kline) -> Optional[Signal]:
|
||||||
|
self._closes_30m.append(kline.close)
|
||||||
|
self._ema_30m = ema(self._closes_30m, self.cfg.entry_ema_period)
|
||||||
|
|
||||||
|
n = len(self._closes_30m)
|
||||||
|
if n < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cur_ema = self._ema_30m[-1]
|
||||||
|
prev_ema = self._ema_30m[-2]
|
||||||
|
|
||||||
|
if cur_ema == 0.0 or prev_ema == 0.0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cur_price = kline.close
|
||||||
|
|
||||||
|
# 30m K线收阳
|
||||||
|
is_bullish_bar = kline.close > kline.open
|
||||||
|
|
||||||
|
# 30m EMA 斜率(最近3根是否递增)
|
||||||
|
ema_sloping_up = (
|
||||||
|
n >= 4
|
||||||
|
and self._ema_30m[-1] > self._ema_30m[-2]
|
||||||
|
and self._ema_30m[-2] > self._ema_30m[-3]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4h 趋势
|
||||||
|
is_uptrend, price_4h, ema_4h = self._get_4h_trend(kline.open_time)
|
||||||
|
|
||||||
|
# ── 出场 ──
|
||||||
|
if self._has_position:
|
||||||
|
# 仅 4h 趋势转空时出场(让 30m 波动自然消化)
|
||||||
|
if not is_uptrend and price_4h > 0:
|
||||||
|
self._has_position = False
|
||||||
|
return Signal(
|
||||||
|
symbol=self.cfg.symbol,
|
||||||
|
side="SELL",
|
||||||
|
signal_type="MARKET",
|
||||||
|
confidence=0.9,
|
||||||
|
reason=f"4h转空 P={price_4h:.0f}<EMA{self.cfg.trend_ema_period}={ema_4h:.0f}",
|
||||||
|
timestamp=kline.open_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 入场 ──
|
||||||
|
if not self._has_position:
|
||||||
|
if not is_uptrend:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 30m 价格在 EMA 上方 + EMA 上行 + 收阳 → 买入
|
||||||
|
price_above_ema = cur_price > cur_ema
|
||||||
|
if price_above_ema and ema_sloping_up and is_bullish_bar:
|
||||||
|
self._has_position = True
|
||||||
|
return Signal(
|
||||||
|
symbol=self.cfg.symbol,
|
||||||
|
side="BUY",
|
||||||
|
signal_type="MARKET",
|
||||||
|
confidence=0.7,
|
||||||
|
reason=(
|
||||||
|
f"30m多头确认 | "
|
||||||
|
f"4hP={price_4h:.0f}>E={ema_4h:.0f}"
|
||||||
|
),
|
||||||
|
timestamp=kline.open_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 主函数
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
bt_config = BacktestConfig(
|
||||||
|
symbol="ETHUSDT",
|
||||||
|
interval="30m",
|
||||||
|
start_time=datetime(2024, 1, 1),
|
||||||
|
end_time=datetime(2026, 1, 1),
|
||||||
|
initial_capital=10_000.0,
|
||||||
|
commission_pct=0.001,
|
||||||
|
slippage_pct=0.0005,
|
||||||
|
warmup_bars=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
strategy_config = DualEMATFConfig(
|
||||||
|
name="dual_ema_eth",
|
||||||
|
symbol="ETHUSDT",
|
||||||
|
trend_ema_period=50,
|
||||||
|
entry_ema_period=20,
|
||||||
|
data_start=bt_config.start_time,
|
||||||
|
data_end=bt_config.end_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("╔" + "═" * 60 + "╗")
|
||||||
|
print("║" + " 多周期策略 v2 — 双周期同指标 (EMA)".center(54) + "║")
|
||||||
|
print("╠" + "═" * 60 + "╣")
|
||||||
|
print(f"║ {'交易对:':<8} {bt_config.symbol:<14} {'周期:':<6} {bt_config.interval:<12} ║")
|
||||||
|
print(f"║ {'时间:':<8} {bt_config.start_time.date()} ~ {bt_config.end_time.date()} ║")
|
||||||
|
print("╠" + "═" * 60 + "╣")
|
||||||
|
print("║ 同指标 · 双周期: ║")
|
||||||
|
print(f"║ 4h EMA{strategy_config.trend_ema_period} → 趋势方向(价格在上=多头) ║")
|
||||||
|
print(f"║ 30m EMA{strategy_config.entry_ema_period} → 价格在上+EMA上行+收阳=买入 ║")
|
||||||
|
print("╚" + "═" * 60 + "╝")
|
||||||
|
print()
|
||||||
|
|
||||||
|
engine = BacktestEngine(bt_config, db_config=config.db)
|
||||||
|
result = await engine.run(DualEMATFStrategy, strategy_config)
|
||||||
|
|
||||||
|
print(result.summary())
|
||||||
|
|
||||||
|
sells = [t for t in result.trades if t.pnl is not None]
|
||||||
|
if sells:
|
||||||
|
print(f"\n最近 10 笔平仓:")
|
||||||
|
print(f"{'时间':<22} {'方向':<6} {'价格':>10} {'盈亏':>10} 原因")
|
||||||
|
print("-" * 80)
|
||||||
|
for t in sells[-10:]:
|
||||||
|
dt = datetime.fromtimestamp(t.timestamp / 1000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
|
||||||
|
print(f"{dt:<22} {t.side:<6} {t.price:>10.2f} {t.pnl:>+10.2f} {t.reason}")
|
||||||
|
|
||||||
|
print("\n回测完成。")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""
|
||||||
|
4h EMA50>EMA200 定大势 / 30m 找买点
|
||||||
|
|
||||||
|
策略:
|
||||||
|
4h EMA50 > EMA200 → 中长期多头趋势确认,允许做多
|
||||||
|
30m 入场 → 4h多头区间中,30m价格上穿EMA20 + 收阳 → 买入
|
||||||
|
30m 出场 → 下穿EMA20 或 4h趋势打破(EMA50<EMA200) 或 ATR止损
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
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
|
||||||
|
from engine.data import DataService
|
||||||
|
from engine.indicators import ema, atr
|
||||||
|
|
||||||
|
|
||||||
|
class EMA200FilterConfig(StrategyConfig):
|
||||||
|
ema30_fast: int = 10 # 30m 快线
|
||||||
|
ema30_slow: int = 50 # 30m 慢线
|
||||||
|
atr_stop: float = 3.0
|
||||||
|
data_start: Optional[datetime] = None
|
||||||
|
data_end: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class EMA200FilterStrategy(BaseStrategy):
|
||||||
|
"""4h EMA50>200 多头趋势 + 30m 双EMA金叉
|
||||||
|
|
||||||
|
30m 用双EMA交叉替代价格穿越,大幅减少假信号。
|
||||||
|
"""
|
||||||
|
|
||||||
|
strategy_type = "ema200_filter"
|
||||||
|
|
||||||
|
def __init__(self, c: EMA200FilterConfig):
|
||||||
|
super().__init__(c)
|
||||||
|
self.cfg = c
|
||||||
|
self._klines_4h: list[Kline] = []
|
||||||
|
|
||||||
|
self._closes: list[float] = []
|
||||||
|
self._highs: list[float] = []
|
||||||
|
self._lows: list[float] = []
|
||||||
|
self._highest: float = 0.0
|
||||||
|
self._in_position = False
|
||||||
|
|
||||||
|
async def on_start(self):
|
||||||
|
from engine.common.config import config as app_config
|
||||||
|
ds = DataService(app_config.db)
|
||||||
|
await ds.connect()
|
||||||
|
try:
|
||||||
|
self._klines_4h = await ds.fetch_klines(
|
||||||
|
symbol=self.cfg.symbol, interval="4h",
|
||||||
|
start_time=datetime(2023, 1, 1),
|
||||||
|
end_time=self.cfg.data_end,
|
||||||
|
limit=1_000_000,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await ds.close()
|
||||||
|
await super().on_start()
|
||||||
|
|
||||||
|
def _is_4h_bull(self, ts: float) -> bool:
|
||||||
|
if len(self._klines_4h) < 201:
|
||||||
|
return False
|
||||||
|
if not hasattr(self, '_ema50_4h'):
|
||||||
|
closes = [k.close for k in self._klines_4h]
|
||||||
|
self._ema50_4h = ema(closes, 50)
|
||||||
|
self._ema200_4h = ema(closes, 200)
|
||||||
|
for i in range(len(self._klines_4h) - 1, -1, -1):
|
||||||
|
if self._klines_4h[i].close_time <= ts:
|
||||||
|
e50 = self._ema50_4h[i]
|
||||||
|
e200 = self._ema200_4h[i]
|
||||||
|
return e50 > 0 and e200 > 0 and e50 > e200
|
||||||
|
return 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)
|
||||||
|
n = len(self._closes)
|
||||||
|
need = self.cfg.ema30_slow + 10
|
||||||
|
if n < need:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 30m 双EMA(金叉/死叉)
|
||||||
|
fast = ema(self._closes, self.cfg.ema30_fast)
|
||||||
|
slow = ema(self._closes, self.cfg.ema30_slow)
|
||||||
|
atr_vals = atr(self._highs, self._lows, self._closes, 14)
|
||||||
|
cur_f, cur_s = fast[-1], slow[-1]
|
||||||
|
prev_f, prev_s = fast[-2], slow[-2]
|
||||||
|
cur_atr = atr_vals[-1]
|
||||||
|
if cur_f == 0 or cur_s == 0 or cur_atr == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
is_bull = self._is_4h_bull(k.open_time)
|
||||||
|
|
||||||
|
# ── 出场 ──
|
||||||
|
if self._in_position:
|
||||||
|
self._highest = max(self._highest, k.high)
|
||||||
|
stop = self._highest - self.cfg.atr_stop * cur_atr
|
||||||
|
death = prev_f >= prev_s and cur_f < cur_s
|
||||||
|
|
||||||
|
if not is_bull:
|
||||||
|
self._in_position = False
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason="4h转空", timestamp=k.open_time)
|
||||||
|
if k.close < stop:
|
||||||
|
self._in_position = False
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason="ATR止损", timestamp=k.open_time)
|
||||||
|
if death:
|
||||||
|
self._in_position = False
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL",
|
||||||
|
reason=f"30m EMA死叉", timestamp=k.open_time)
|
||||||
|
|
||||||
|
# ── 入场 ──
|
||||||
|
if not self._in_position and is_bull:
|
||||||
|
golden = prev_f <= prev_s and cur_f > cur_s
|
||||||
|
if golden and k.close > k.open:
|
||||||
|
self._in_position = True
|
||||||
|
self._highest = k.close
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="BUY",
|
||||||
|
reason=f"4h多头+30m金叉", timestamp=k.open_time)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
|
||||||
|
DATE_START = datetime(2024, 1, 1)
|
||||||
|
DATE_END = datetime(2026, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print()
|
||||||
|
print("═" * 105)
|
||||||
|
print(" 4h EMA50>EMA200 定大势 / 30m 双EMA金叉 | 2024-2026")
|
||||||
|
print("═" * 105)
|
||||||
|
print(f" {'币种':<10} {'收益%':>7} {'夏普':>6} {'回撤%':>7} {'交易':>5} {'胜率%':>6} {'盈亏比':>6} {'持有%':>6}")
|
||||||
|
print("─" * 105)
|
||||||
|
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
sc = EMA200FilterConfig(symbol=symbol, data_start=DATE_START, data_end=DATE_END)
|
||||||
|
bt = BacktestConfig(symbol=symbol, interval="30m",
|
||||||
|
start_time=DATE_START, end_time=DATE_END, initial_capital=10_000.0,
|
||||||
|
warmup_bars=50)
|
||||||
|
engine = BacktestEngine(bt, db_config=config.db)
|
||||||
|
r = await engine.run(EMA200FilterStrategy, sc)
|
||||||
|
m = r.metrics
|
||||||
|
|
||||||
|
# 持仓时间占比
|
||||||
|
if r.equity_curve:
|
||||||
|
bars_with_position = sum(1 for e in r.equity_curve if e.get("position", 0) > 0)
|
||||||
|
position_pct = bars_with_position / len(r.equity_curve) * 100
|
||||||
|
else:
|
||||||
|
position_pct = 0
|
||||||
|
|
||||||
|
print(f" {symbol:<10} {m.total_return_pct:>6.1f}% {m.sharpe_ratio:>6.2f} {m.max_drawdown_pct:>6.1f}% {m.total_trades:>5} {m.win_rate*100:>5.1f}% {m.profit_factor:>6.2f} {position_pct:>5.0f}%")
|
||||||
|
|
||||||
|
# 最近平仓
|
||||||
|
sells = [t for t in r.trades if t.side == "SELL" and t.pnl is not None]
|
||||||
|
if sells:
|
||||||
|
for t in sells[-2:]:
|
||||||
|
dt = datetime.fromtimestamp(t.timestamp / 1000, tz=timezone.utc).strftime("%m-%d %H:%M")
|
||||||
|
print(f" {'':<10} └ {dt} {t.pnl:>+8.2f} USDT {t.reason}")
|
||||||
|
|
||||||
|
print("─" * 105)
|
||||||
|
|
||||||
|
# 对比之前最优
|
||||||
|
print("\n ■ 对比:之前最优策略")
|
||||||
|
BEST = {
|
||||||
|
"BTCUSDT": ("EMA v3(10,50) 4h", 39.9, 1.03, -11.5, 20),
|
||||||
|
"ETHUSDT": ("EMA v3(10,75) 4h", 53.6, 1.04, -15.3, 18),
|
||||||
|
"BNBUSDT": ("EMA v1(20,50) 4h", 52.0, 0.71, -39.8, 41),
|
||||||
|
"SOLUSDT": ("EMA v3(30,50) 4h", 73.6, 1.18, -25.7, 13),
|
||||||
|
}
|
||||||
|
print(f" {'币种':<10} {'策略':<22} {'收益%':>7} {'夏普':>6} {'回撤%':>7} {'交易':>5}")
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
name, ret, sh, dd, tr = BEST[symbol]
|
||||||
|
print(f" {symbol:<10} {name:<22} {ret:>6.1f}% {sh:>6.2f} {dd:>6.1f}% {tr:>5}")
|
||||||
|
|
||||||
|
print("\n═" * 105)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"""
|
||||||
|
多时间框架 v3 — 1d 定趋势 / 4h 找买点
|
||||||
|
|
||||||
|
策略:
|
||||||
|
1d EMA(20,50) → 日线金叉=多头趋势,死叉=空头
|
||||||
|
4h 回调入场 → 日线多头中,4h 价格回调到 EMA20 附近 + 收阳 → 买入
|
||||||
|
4h EMA死叉 或 日线转空 → 卖出
|
||||||
|
ATR 动态止损
|
||||||
|
|
||||||
|
币种:BTC/ETH/BNB/SOL | 2024-2026
|
||||||
|
|
||||||
|
用法:
|
||||||
|
source .venv/bin/activate && python example/multi_tf_v3.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
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.data import DataService
|
||||||
|
from engine.indicators import ema, atr
|
||||||
|
|
||||||
|
|
||||||
|
class DailyTrendConfig(StrategyConfig):
|
||||||
|
"""1d趋势+4h交易 策略配置"""
|
||||||
|
|
||||||
|
# 1d 趋势参数
|
||||||
|
trend_fast: int = 20
|
||||||
|
trend_slow: int = 50
|
||||||
|
|
||||||
|
# 4h 交易参数
|
||||||
|
entry_ema: int = 20 # 4h 回调到该均线附近买入
|
||||||
|
atr_period: int = 14
|
||||||
|
atr_stop_mult: float = 2.5
|
||||||
|
|
||||||
|
# 数据范围
|
||||||
|
data_start: Optional[datetime] = None
|
||||||
|
data_end: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DailyTrendStrategy(BaseStrategy):
|
||||||
|
"""日线定大势,4h 抓回调
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ 1d EMA(20,50) │
|
||||||
|
│ ├─ 金叉 → 多头区间 │
|
||||||
|
│ └─ 死叉 → 空仓等待 │
|
||||||
|
│ │
|
||||||
|
│ 4h K线(引擎推送) │
|
||||||
|
│ ├─ 多头区间 + 回调到 EMA20 + 收阳 → 买入 │
|
||||||
|
│ ├─ EMA死叉 → 卖出 │
|
||||||
|
│ └─ ATR 动态止损 │
|
||||||
|
└──────────────────────────────────────────────┘
|
||||||
|
"""
|
||||||
|
|
||||||
|
strategy_type = "daily_trend"
|
||||||
|
|
||||||
|
def __init__(self, c: DailyTrendConfig):
|
||||||
|
super().__init__(c)
|
||||||
|
self.cfg = c
|
||||||
|
|
||||||
|
# 1d 预加载
|
||||||
|
self._klines_1d: list[Kline] = []
|
||||||
|
self._ema_fast_1d: list[float] = []
|
||||||
|
self._ema_slow_1d: list[float] = []
|
||||||
|
|
||||||
|
# 4h 积累
|
||||||
|
self._closes: list[float] = []
|
||||||
|
self._highs: list[float] = []
|
||||||
|
self._lows: list[float] = []
|
||||||
|
|
||||||
|
self._highest: float = 0.0
|
||||||
|
self._in_position = False
|
||||||
|
|
||||||
|
async def on_start(self):
|
||||||
|
from engine.common.config import config as app_config
|
||||||
|
ds = DataService(app_config.db)
|
||||||
|
await ds.connect()
|
||||||
|
try:
|
||||||
|
self._klines_1d = await ds.fetch_klines(
|
||||||
|
symbol=self.cfg.symbol, interval="1d",
|
||||||
|
start_time=self.cfg.data_start, end_time=self.cfg.data_end, limit=1_000_000,
|
||||||
|
)
|
||||||
|
closes_1d = [k.close for k in self._klines_1d]
|
||||||
|
self._ema_fast_1d = ema(closes_1d, self.cfg.trend_fast)
|
||||||
|
self._ema_slow_1d = ema(closes_1d, self.cfg.trend_slow)
|
||||||
|
finally:
|
||||||
|
await ds.close()
|
||||||
|
await super().on_start()
|
||||||
|
|
||||||
|
def _get_1d_state(self, ts: float) -> tuple[bool, bool, float, float]:
|
||||||
|
"""获取日线趋势状态(仅已完成K线)
|
||||||
|
Returns: (is_bull, is_golden_cross, price, ema_fast)
|
||||||
|
"""
|
||||||
|
if not self._klines_1d:
|
||||||
|
return False, False, 0.0, 0.0
|
||||||
|
for i in range(len(self._klines_1d) - 1, -1, -1):
|
||||||
|
if self._klines_1d[i].close_time <= ts:
|
||||||
|
ef = self._ema_fast_1d[i]
|
||||||
|
es = self._ema_slow_1d[i]
|
||||||
|
if ef == 0 or es == 0:
|
||||||
|
return False, False, self._klines_1d[i].close, ef
|
||||||
|
# 金叉 = EMA20 > EMA50
|
||||||
|
golden = ef > es
|
||||||
|
# 趋势向上 = 价格在EMA20上方(额外确认)
|
||||||
|
price_above = self._klines_1d[i].close > ef
|
||||||
|
return golden and price_above, golden, self._klines_1d[i].close, ef
|
||||||
|
return False, False, 0.0, 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)
|
||||||
|
n = len(self._closes)
|
||||||
|
if n < self.cfg.entry_ema + 20:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ema_4h = ema(self._closes, self.cfg.entry_ema)
|
||||||
|
atr_vals = atr(self._highs, self._lows, self._closes, self.cfg.atr_period)
|
||||||
|
cur_ema, cur_atr = ema_4h[-1], atr_vals[-1]
|
||||||
|
if cur_ema == 0 or cur_atr == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
is_bull, is_golden, price_1d, ema_1d = self._get_1d_state(k.open_time)
|
||||||
|
|
||||||
|
# ── 出场 ──
|
||||||
|
if self._in_position:
|
||||||
|
self._highest = max(self._highest, k.high)
|
||||||
|
stop = self._highest - self.cfg.atr_stop_mult * cur_atr
|
||||||
|
|
||||||
|
# 4h EMA死叉(用EMA9代替,更快反应)
|
||||||
|
ema9_vals = ema(self._closes, 9)
|
||||||
|
ema20_vals = ema(self._closes, 20)
|
||||||
|
if len(ema9_vals) > 2 and ema9_vals[-2] >= ema20_vals[-2] and ema9_vals[-1] < ema20_vals[-1]:
|
||||||
|
self._in_position = False
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason="4h EMA死叉", timestamp=k.open_time)
|
||||||
|
|
||||||
|
if k.close < stop:
|
||||||
|
self._in_position = False
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason=f"ATR止损", timestamp=k.open_time)
|
||||||
|
|
||||||
|
# ── 入场 ──
|
||||||
|
if not self._in_position and is_bull:
|
||||||
|
# 4h 价格回调到 EMA20 附近(偏离不超过 1.5 倍 ATR)
|
||||||
|
distance = abs(k.close - cur_ema) / cur_atr
|
||||||
|
near_ema = distance < 1.5
|
||||||
|
# K线收阳
|
||||||
|
bullish_bar = k.close > k.open
|
||||||
|
# EMA20 走平或向上
|
||||||
|
ema_rising = n >= 4 and ema_4h[-1] >= ema_4h[-3]
|
||||||
|
|
||||||
|
if near_ema and bullish_bar and ema_rising:
|
||||||
|
self._in_position = True
|
||||||
|
self._highest = k.close
|
||||||
|
return Signal(
|
||||||
|
symbol=self.cfg.symbol, side="BUY",
|
||||||
|
confidence=0.8,
|
||||||
|
reason=f"1d多头+4h回调 P={k.close:.0f}≈EMA={cur_ema:.0f} dist={distance:.1f}σ",
|
||||||
|
timestamp=k.open_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# 运行
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
|
||||||
|
DATE_START = datetime(2024, 1, 1)
|
||||||
|
DATE_END = datetime(2026, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
# v3 EMA最优结果(用于对比)
|
||||||
|
EMA_V3 = {
|
||||||
|
"BTCUSDT": (39.9, 1.03, -11.5, 20, 55.0),
|
||||||
|
"ETHUSDT": (53.6, 1.04, -15.3, 18, 38.9),
|
||||||
|
"BNBUSDT": (26.0, 0.64, -23.4, 23, 34.8),
|
||||||
|
"SOLUSDT": (73.6, 1.18, -25.7, 13, 46.2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print()
|
||||||
|
print("═" * 110)
|
||||||
|
print(" 多时间框架 v3 — 1d 定趋势 / 4h 找回调买点 | 2024-2026")
|
||||||
|
print("═" * 110)
|
||||||
|
print(f" {'币种':<10} {'版本':<22} {'收益%':>7} {'夏普':>6} {'回撤%':>7} {'交易':>5} {'胜率%':>6} {'盈亏比':>6}")
|
||||||
|
print("─" * 110)
|
||||||
|
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
sc = DailyTrendConfig(
|
||||||
|
symbol=symbol,
|
||||||
|
data_start=DATE_START, data_end=DATE_END,
|
||||||
|
)
|
||||||
|
bt = BacktestConfig(symbol=symbol, interval="4h",
|
||||||
|
start_time=DATE_START, end_time=DATE_END, initial_capital=10_000.0)
|
||||||
|
engine = BacktestEngine(bt, db_config=config.db)
|
||||||
|
r = await engine.run(DailyTrendStrategy, sc)
|
||||||
|
m = r.metrics
|
||||||
|
|
||||||
|
# v3 对比
|
||||||
|
v3 = EMA_V3[symbol]
|
||||||
|
print(f" {symbol:<10} {'1d趋势+4h回调':<22} {m.total_return_pct:>6.1f}% {m.sharpe_ratio:>6.2f} {m.max_drawdown_pct:>6.1f}% {m.total_trades:>5} {m.win_rate*100:>5.1f}% {m.profit_factor:>6.2f}")
|
||||||
|
print(f" {'':<10} {'(对比) EMA v3 最优':<22} {v3[0]:>6.1f}% {v3[1]:>6.2f} {v3[2]:>6.1f}% {v3[3]:>5} {v3[4]:>5.1f}%")
|
||||||
|
|
||||||
|
# 打印最近几笔出场
|
||||||
|
sells = [t for t in r.trades if t.side == "SELL" and t.pnl is not None]
|
||||||
|
if sells:
|
||||||
|
recent = sells[-3:]
|
||||||
|
for t in recent:
|
||||||
|
dt = datetime.fromtimestamp(t.timestamp / 1000, tz=timezone.utc).strftime("%m-%d %H:%M")
|
||||||
|
print(f" {'':<10} └ {dt} {t.pnl:>+8.2f} USDT {t.reason}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("═" * 110)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"""
|
||||||
|
最佳牛熊判定 — 全币种全周期回测
|
||||||
|
|
||||||
|
方法:EMA200斜率 + 价格vs EMA200 + ATH回撤,3选2投票
|
||||||
|
策略:牛市只做多 / 熊市只做空 / 震荡空仓
|
||||||
|
币种:BTC / ETH / BNB / SOL,各自最早有数据到2026
|
||||||
|
|
||||||
|
用法:
|
||||||
|
source .venv/bin/activate && python example/regime_all.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
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.indicators import ema, atr
|
||||||
|
from engine.data import DataService
|
||||||
|
from engine.example.long_short import LongShortEngine
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# 3法判定器
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class RegimeDetector3:
|
||||||
|
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: list[float], idx: int) -> str:
|
||||||
|
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: list[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"
|
||||||
|
|
||||||
|
def ath_drawdown(self, closes: list[float], idx: int) -> str:
|
||||||
|
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: list[float], idx: int) -> str:
|
||||||
|
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 RegimeEmaConfig(StrategyConfig):
|
||||||
|
fast: int = 10; slow: int = 50; atr_stop: float = 2.5
|
||||||
|
|
||||||
|
|
||||||
|
class RegimeEmaStrategy(BaseStrategy):
|
||||||
|
"""按市场状态自适应做多/做空"""
|
||||||
|
|
||||||
|
strategy_type = "regime_ema"
|
||||||
|
|
||||||
|
def __init__(self, c: RegimeEmaConfig):
|
||||||
|
super().__init__(c)
|
||||||
|
self.cfg = c
|
||||||
|
self._c: list[float] = []; self._h: list[float] = []; self._l: list[float] = []
|
||||||
|
self._detector = RegimeDetector3()
|
||||||
|
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)
|
||||||
|
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 = ""
|
||||||
|
reason = "死叉" if death else ("ATR止损" if k.close < stop else "转熊")
|
||||||
|
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 or regime == "bull":
|
||||||
|
self._side = ""
|
||||||
|
reason = "金叉" if golden else ("ATR止损" if k.close > stop else "转牛")
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="BUY", reason=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=f"牛市金叉", 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=f"熊市死叉", timestamp=k.open_time)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
|
||||||
|
|
||||||
|
PARAMS = {
|
||||||
|
"BTCUSDT": (10, 50),
|
||||||
|
"ETHUSDT": (10, 75),
|
||||||
|
"BNBUSDT": (20, 50),
|
||||||
|
"SOLUSDT": (30, 50),
|
||||||
|
}
|
||||||
|
|
||||||
|
DATE_START = datetime(2017, 1, 1)
|
||||||
|
DATE_END = datetime(2026, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_actual_range(symbol: str) -> tuple[datetime, datetime]:
|
||||||
|
"""获取币种实际数据范围"""
|
||||||
|
ds = DataService(config.db)
|
||||||
|
await ds.connect()
|
||||||
|
try:
|
||||||
|
start, end = await ds.fetch_symbol_date_range(symbol, "4h")
|
||||||
|
return start, end
|
||||||
|
except:
|
||||||
|
return DATE_START, DATE_END
|
||||||
|
finally:
|
||||||
|
await ds.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print()
|
||||||
|
print("═" * 125)
|
||||||
|
print(" 牛熊自适应策略 — 全币种全周期 | 牛市只多/熊市只空/震荡空仓")
|
||||||
|
print("═" * 125)
|
||||||
|
|
||||||
|
print(f"\n ■ 全周期汇总")
|
||||||
|
print(f" {'币种':<10} {'数据范围':<22} {'总收益%':>7} {'年化%':>7} {'夏普':>6} {'回撤%':>7} {'交易':>5} {'多头P&L':>10} {'空头P&L':>10}")
|
||||||
|
print(" " + "─" * 115)
|
||||||
|
|
||||||
|
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(symbol)
|
||||||
|
range_str = f"{act_start.date()}~{act_end.date()}"
|
||||||
|
except:
|
||||||
|
act_start, act_end = DATE_START, DATE_END
|
||||||
|
range_str = "2017-2026"
|
||||||
|
|
||||||
|
bt = BacktestConfig(symbol=symbol, interval="4h",
|
||||||
|
start_time=act_start, end_time=act_end, initial_capital=10_000.0)
|
||||||
|
engine = LongShortEngine(bt, db_config=config.db)
|
||||||
|
r = await engine.run(RegimeEmaStrategy, sc)
|
||||||
|
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:>6.1f}% {m.annual_return_pct:>6.1f}% {m.sharpe_ratio:>6.2f} {m.max_drawdown_pct:>6.1f}% {m.total_trades:>5} {long_pnl:>+9.0f} {short_pnl:>+9.0f}")
|
||||||
|
|
||||||
|
# ── BTC 分段 ──
|
||||||
|
PERIODS = [
|
||||||
|
("2017 牛市", datetime(2017,1,1), datetime(2018,1,1)),
|
||||||
|
("2018 熊市", datetime(2018,1,1), datetime(2019,1,1)),
|
||||||
|
("2019 反弹", datetime(2019,1,1), datetime(2020,1,1)),
|
||||||
|
("2020 牛初", datetime(2020,1,1), datetime(2021,1,1)),
|
||||||
|
("2021 牛市", datetime(2021,1,1), datetime(2022,1,1)),
|
||||||
|
("2022 熊市", datetime(2022,1,1), datetime(2023,1,1)),
|
||||||
|
("2023 震荡", datetime(2023,1,1), datetime(2024,1,1)),
|
||||||
|
("2024-25牛", datetime(2024,1,1), datetime(2026,1,1)),
|
||||||
|
]
|
||||||
|
print(f"\n ■ BTC 分段表现")
|
||||||
|
print(f" {'阶段':<16} {'总收益%':>7} {'夏普':>6} {'多头P&L':>9} {'空头P&L':>9}")
|
||||||
|
print(" " + "─" * 65)
|
||||||
|
for name, s, e in PERIODS:
|
||||||
|
try:
|
||||||
|
sc = RegimeEmaConfig(symbol="BTCUSDT", fast=10, slow=50)
|
||||||
|
bt = BacktestConfig(symbol="BTCUSDT", interval="4h", start_time=s, end_time=e, initial_capital=10_000.0)
|
||||||
|
eng = LongShortEngine(bt, db_config=config.db)
|
||||||
|
r = await eng.run(RegimeEmaStrategy, sc)
|
||||||
|
lt = [t for t in r.trades if t.pnl is not None and t.side == "SELL"]
|
||||||
|
st = [t for t in r.trades if t.pnl is not None and t.side == "BUY"]
|
||||||
|
print(f" {name:<16} {r.metrics.total_return_pct:>+6.1f}% {r.metrics.sharpe_ratio:>6.2f} {sum(t.pnl for t in lt) if lt else 0:>+8.0f} {sum(t.pnl for t in st) if st else 0:>+8.0f}")
|
||||||
|
except Exception as ex:
|
||||||
|
print(f" {name:<16} 数据不足")
|
||||||
|
|
||||||
|
print("\n═" * 125)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"""
|
||||||
|
市场状态识别 — 牛市/熊市判定方法对比 + 自适应策略回测
|
||||||
|
|
||||||
|
方法:
|
||||||
|
1. EMA200 斜率 — EMA200 向上=牛,向下=熊
|
||||||
|
2. 价格 vs EMA200 — Price > EMA200 = 牛
|
||||||
|
3. ATH 回撤 — 距历史高点 < 20% = 牛,> 20% = 熊
|
||||||
|
4. 综合投票 — 三选二
|
||||||
|
|
||||||
|
根据识别结果自动偏多/偏空:
|
||||||
|
牛市:只做多(金叉买入,死叉平仓)
|
||||||
|
熊市:只做空(死叉做空,金叉平仓)
|
||||||
|
震荡(票数2:1或无共识):空仓等待
|
||||||
|
|
||||||
|
BTC 2017-2026 全周期测试
|
||||||
|
|
||||||
|
用法:
|
||||||
|
source .venv/bin/activate && python example/regime_detect.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
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.indicators import ema, atr
|
||||||
|
from engine.example.long_short import LongShortEngine
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# 市场状态识别器
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class RegimeDetector:
|
||||||
|
"""市场状态识别:牛/熊/震荡"""
|
||||||
|
|
||||||
|
def __init__(self, closes: list[float]):
|
||||||
|
self._c = closes
|
||||||
|
self._ath = 0.0
|
||||||
|
self._ath_tracking = [] # 追踪历史高点序列
|
||||||
|
|
||||||
|
def update_ath(self, price: float):
|
||||||
|
if price > self._ath:
|
||||||
|
self._ath = price
|
||||||
|
self._ath_tracking.append(self._ath)
|
||||||
|
|
||||||
|
def ema200_slope(self, idx: int) -> str:
|
||||||
|
"""EMA200 斜率判定"""
|
||||||
|
if idx < 202:
|
||||||
|
return "unknown"
|
||||||
|
e200 = ema(self._c, 200)
|
||||||
|
# 最近5根EMA200的斜率
|
||||||
|
if e200[idx] == 0 or e200[max(0, idx - 5)] == 0:
|
||||||
|
return "unknown"
|
||||||
|
slope = (e200[idx] - e200[max(0, idx - 5)]) / e200[max(0, idx - 5)]
|
||||||
|
if slope > 0.001:
|
||||||
|
return "bull"
|
||||||
|
elif slope < -0.001:
|
||||||
|
return "bear"
|
||||||
|
return "sideways"
|
||||||
|
|
||||||
|
def price_vs_ema200(self, idx: int) -> str:
|
||||||
|
"""价格 vs EMA200"""
|
||||||
|
if idx < 202:
|
||||||
|
return "unknown"
|
||||||
|
e200 = ema(self._c, 200)
|
||||||
|
if e200[idx] == 0:
|
||||||
|
return "unknown"
|
||||||
|
return "bull" if self._c[idx] > e200[idx] else "bear"
|
||||||
|
|
||||||
|
def ath_drawdown(self, idx: int) -> str:
|
||||||
|
"""ATH 回撤判定(经典加密牛熊指标)"""
|
||||||
|
if not self._ath_tracking or idx >= len(self._ath_tracking):
|
||||||
|
return "unknown"
|
||||||
|
curr_ath = self._ath_tracking[idx]
|
||||||
|
if curr_ath == 0:
|
||||||
|
return "unknown"
|
||||||
|
dd = (self._c[idx] - curr_ath) / curr_ath
|
||||||
|
if dd > -0.20:
|
||||||
|
return "bull"
|
||||||
|
elif dd < -0.40:
|
||||||
|
return "bear"
|
||||||
|
return "sideways"
|
||||||
|
|
||||||
|
def combined(self, idx: int) -> tuple[str, str, str, str]:
|
||||||
|
"""综合判定"""
|
||||||
|
r1 = self.ema200_slope(idx)
|
||||||
|
r2 = self.price_vs_ema200(idx)
|
||||||
|
r3 = self.ath_drawdown(idx)
|
||||||
|
|
||||||
|
votes = {"bull": 0, "bear": 0}
|
||||||
|
for r in [r1, r2, r3]:
|
||||||
|
if r in votes:
|
||||||
|
votes[r] += 1
|
||||||
|
|
||||||
|
if votes["bull"] >= 2:
|
||||||
|
return "bull", r1, r2, r3
|
||||||
|
elif votes["bear"] >= 2:
|
||||||
|
return "bear", r1, r2, r3
|
||||||
|
return "sideways", r1, r2, r3
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# 自适应策略(根据市场状态偏多/偏空)
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class AdaptiveEmaConfig(StrategyConfig):
|
||||||
|
fast: int = 10
|
||||||
|
slow: int = 50
|
||||||
|
atr_stop: float = 2.5
|
||||||
|
|
||||||
|
|
||||||
|
class AdaptiveEmaStrategy(BaseStrategy):
|
||||||
|
"""牛市只做多、熊市只做空、震荡空仓"""
|
||||||
|
|
||||||
|
strategy_type = "adaptive_ema"
|
||||||
|
|
||||||
|
def __init__(self, c: AdaptiveEmaConfig):
|
||||||
|
super().__init__(c)
|
||||||
|
self.cfg = c
|
||||||
|
self._closes: list[float] = []
|
||||||
|
self._highs: list[float] = []
|
||||||
|
self._lows: list[float] = []
|
||||||
|
self._detector: Optional[RegimeDetector] = None
|
||||||
|
self._position_side: str = "" # "long" / "short"
|
||||||
|
self._highest: float = 0.0
|
||||||
|
self._lowest: float = float('inf')
|
||||||
|
|
||||||
|
async def on_kline(self, k: Kline) -> Optional[Signal]:
|
||||||
|
self._closes.append(k.close)
|
||||||
|
self._highs.append(k.high)
|
||||||
|
self._lows.append(k.low)
|
||||||
|
|
||||||
|
if self._detector is None:
|
||||||
|
self._detector = RegimeDetector(self._closes)
|
||||||
|
self._detector.update_ath(k.close)
|
||||||
|
|
||||||
|
n = len(self._closes)
|
||||||
|
if n < 210:
|
||||||
|
return None
|
||||||
|
|
||||||
|
regime, r1, r2, r3 = self._detector.combined(n - 1)
|
||||||
|
|
||||||
|
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]
|
||||||
|
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 or k.close < stop or regime == "bear":
|
||||||
|
self._position_side = ""
|
||||||
|
reason = "死叉" if death else ("ATR止损" if k.close < stop else "转熊市")
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason=reason, 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 or k.close > stop or regime == "bull":
|
||||||
|
self._position_side = ""
|
||||||
|
reason = "金叉" if golden else ("ATR止损" if k.close > stop else "转牛市")
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="BUY", reason=reason, timestamp=k.open_time)
|
||||||
|
|
||||||
|
# ── 空仓等待 ──
|
||||||
|
else:
|
||||||
|
if regime == "bull" and golden:
|
||||||
|
self._position_side = "long"
|
||||||
|
self._highest = k.close
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="BUY",
|
||||||
|
reason=f"牛市金叉 ({r1}/{r2}/{r3})", timestamp=k.open_time)
|
||||||
|
elif regime == "bear" and death:
|
||||||
|
self._position_side = "short"
|
||||||
|
self._lowest = k.close
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL",
|
||||||
|
reason=f"熊市死叉 ({r1}/{r2}/{r3})", timestamp=k.open_time)
|
||||||
|
# 震荡市:不开仓
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# 对比测试
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
DATE_START = datetime(2017, 1, 1)
|
||||||
|
DATE_END = datetime(2026, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print()
|
||||||
|
print("═" * 120)
|
||||||
|
print(" 市场状态自适应策略 — 牛市只做多 / 熊市只做空 / 震荡空仓")
|
||||||
|
print("═" * 120)
|
||||||
|
|
||||||
|
# ── BTC 自适应 vs 多空 vs 只做多 ──
|
||||||
|
print("\n ■ BTC 全周期 2017-2026 对比")
|
||||||
|
print(f" {'策略':<18} {'总收益%':>7} {'年化%':>7} {'夏普':>6} {'回撤%':>7} {'交易':>5} {'多头P&L':>10} {'空头P&L':>10}")
|
||||||
|
print(" " + "─" * 105)
|
||||||
|
|
||||||
|
# 自适应
|
||||||
|
sc = AdaptiveEmaConfig(symbol="BTCUSDT")
|
||||||
|
bt = BacktestConfig(symbol="BTCUSDT", interval="4h",
|
||||||
|
start_time=DATE_START, end_time=DATE_END, initial_capital=10_000.0)
|
||||||
|
engine = LongShortEngine(bt, db_config=config.db)
|
||||||
|
r = await engine.run(AdaptiveEmaStrategy, sc)
|
||||||
|
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"]
|
||||||
|
print(f" {'自适应(牛多熊空)':<18} {m.total_return_pct:>6.1f}% {m.annual_return_pct:>6.1f}% {m.sharpe_ratio:>6.2f} {m.max_drawdown_pct:>6.1f}% {m.total_trades:>5} {sum(t.pnl for t in long_t) if long_t else 0:>+9.0f} {sum(t.pnl for t in short_t) if short_t else 0:>+9.0f}")
|
||||||
|
|
||||||
|
# 始终多空(之前结果)
|
||||||
|
from engine.example.long_short import LongShortEmaStrategy, LongShortEmaConfig as LSCfg
|
||||||
|
sc2 = LSCfg(symbol="BTCUSDT", fast=10, slow=50)
|
||||||
|
r2 = await engine.run(LongShortEmaStrategy, sc2)
|
||||||
|
m2 = r2.metrics
|
||||||
|
print(f" {'始终多空':<18} {m2.total_return_pct:>6.1f}% {m2.annual_return_pct:>6.1f}% {m2.sharpe_ratio:>6.2f} {m2.max_drawdown_pct:>6.1f}% {m2.total_trades:>5}")
|
||||||
|
|
||||||
|
# 只做多
|
||||||
|
from engine.backtest import BacktestEngine as OrigEngine
|
||||||
|
class LongOnlyS(BaseStrategy):
|
||||||
|
strategy_type = "lo"; _in = False; _hp = 0.0
|
||||||
|
def __init__(self, c): super().__init__(c); self._c = []; self._h = []; self._l = []
|
||||||
|
async def on_start(self): await super().on_start()
|
||||||
|
async def on_kline(self, k):
|
||||||
|
self._c.append(k.close); self._h.append(k.high); self._l.append(k.low)
|
||||||
|
n = len(self._c)
|
||||||
|
if n < 55: return None
|
||||||
|
f = ema(self._c, 10); s = ema(self._c, 50); 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
|
||||||
|
if self._in:
|
||||||
|
self._hp = max(self._hp, k.high); stop = self._hp - 2.5 * ca
|
||||||
|
if (pf >= ps and cf < cs) or k.close < stop:
|
||||||
|
self._in = False
|
||||||
|
return Signal(symbol="BTCUSDT", side="SELL",
|
||||||
|
reason="死叉" if pf>=ps else "ATR止损", timestamp=k.open_time)
|
||||||
|
else:
|
||||||
|
if pf <= ps and cf > cs:
|
||||||
|
self._in = True; self._hp = k.close
|
||||||
|
return Signal(symbol="BTCUSDT", side="BUY", reason="金叉", timestamp=k.open_time)
|
||||||
|
return None
|
||||||
|
from engine.common.base import StrategyConfig as SC
|
||||||
|
lo_bt = BacktestConfig(symbol="BTCUSDT", interval="4h", start_time=DATE_START, end_time=DATE_END, initial_capital=10_000.0)
|
||||||
|
lo_e = OrigEngine(lo_bt, db_config=config.db)
|
||||||
|
lo_r = await lo_e.run(LongOnlyS, SC(symbol="BTCUSDT"))
|
||||||
|
lo_m = lo_r.metrics
|
||||||
|
long_only_t = [t for t in lo_r.trades if t.pnl is not None]
|
||||||
|
print(f" {'只做多':<18} {lo_m.total_return_pct:>6.1f}% {lo_m.annual_return_pct:>6.1f}% {lo_m.sharpe_ratio:>6.2f} {lo_m.max_drawdown_pct:>6.1f}% {lo_m.total_trades:>5}")
|
||||||
|
|
||||||
|
# ── 分段对比 ──
|
||||||
|
PERIODS = [
|
||||||
|
("2017 牛市", datetime(2017,1,1), datetime(2018,1,1)),
|
||||||
|
("2018 熊市", datetime(2018,1,1), datetime(2019,1,1)),
|
||||||
|
("2019 反弹", datetime(2019,1,1), datetime(2020,1,1)),
|
||||||
|
("2020 牛初", datetime(2020,1,1), datetime(2021,1,1)),
|
||||||
|
("2021 牛市", datetime(2021,1,1), datetime(2022,1,1)),
|
||||||
|
("2022 熊市", datetime(2022,1,1), datetime(2023,1,1)),
|
||||||
|
("2023 震荡", datetime(2023,1,1), datetime(2024,1,1)),
|
||||||
|
("2024-25 牛", datetime(2024,1,1), datetime(2026,1,1)),
|
||||||
|
]
|
||||||
|
print(f"\n ■ BTC 分段:自适应 vs 始终多空")
|
||||||
|
print(f" {'阶段':<16} {'自适应':>8} {'始终多空':>8} {'只做多':>8}")
|
||||||
|
print(" " + "─" * 50)
|
||||||
|
for name, s, e in PERIODS:
|
||||||
|
try:
|
||||||
|
bt_p = BacktestConfig(symbol="BTCUSDT", interval="4h", start_time=s, end_time=e, initial_capital=10_000.0)
|
||||||
|
# 自适应
|
||||||
|
sc_p = AdaptiveEmaConfig(symbol="BTCUSDT")
|
||||||
|
e_p = LongShortEngine(bt_p, db_config=config.db)
|
||||||
|
r_p = await e_p.run(AdaptiveEmaStrategy, sc_p)
|
||||||
|
# 始终多空
|
||||||
|
sc_p2 = LSCfg(symbol="BTCUSDT", fast=10, slow=50)
|
||||||
|
r_p2 = await e_p.run(LongShortEmaStrategy, sc_p2)
|
||||||
|
# 只做多
|
||||||
|
lo_e2 = OrigEngine(bt_p, db_config=config.db)
|
||||||
|
lo_r2 = await lo_e2.run(LongOnlyS, SC(symbol="BTCUSDT"))
|
||||||
|
print(f" {name:<16} {r_p.metrics.total_return_pct:>+7.1f}% {r_p2.metrics.total_return_pct:>+7.1f}% {lo_r2.metrics.total_return_pct:>+7.1f}%")
|
||||||
|
except Exception as ex:
|
||||||
|
print(f" {name:<16} 错误: {ex}")
|
||||||
|
|
||||||
|
print("\n═" * 120)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"""
|
||||||
|
牛熊判定方法扩展 + 组合对比
|
||||||
|
|
||||||
|
新增方法:
|
||||||
|
4. Mayer Multiple — Price / EMA200 比值。>1.2=牛,<0.8=熊
|
||||||
|
5. 年同比 — 价格同比去年涨=牛,跌=熊
|
||||||
|
6. 市场结构 — 近200根bar更高高点+更高低点=牛
|
||||||
|
|
||||||
|
对比各种投票组合的效果。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
source .venv/bin/activate && python example/regime_detect2.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
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.indicators import ema, atr
|
||||||
|
from engine.example.long_short import LongShortEngine
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# 扩展版市场状态识别器(6种方法)
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class AdvancedRegimeDetector:
|
||||||
|
|
||||||
|
def __init__(self, closes: list[float], highs: list[float], lows: list[float]):
|
||||||
|
self._c = closes
|
||||||
|
self._h = highs
|
||||||
|
self._l = lows
|
||||||
|
self._ath = 0.0
|
||||||
|
self._ath_tracking = []
|
||||||
|
|
||||||
|
def update_ath(self, price: float):
|
||||||
|
if price > self._ath:
|
||||||
|
self._ath = price
|
||||||
|
self._ath_tracking.append(self._ath)
|
||||||
|
|
||||||
|
# ── 方法1: EMA200 斜率 ──
|
||||||
|
def ema200_slope(self, idx: int) -> str:
|
||||||
|
if idx < 210: return "unknown"
|
||||||
|
e200 = ema(self._c, 200)
|
||||||
|
slope = (e200[idx] - e200[idx - 20]) / e200[idx - 20] if e200[idx - 20] > 0 else 0
|
||||||
|
if slope > 0.002: return "bull"
|
||||||
|
if slope < -0.002: return "bear"
|
||||||
|
return "sideways"
|
||||||
|
|
||||||
|
# ── 方法2: 价格 vs EMA200 ──
|
||||||
|
def price_vs_ema200(self, idx: int) -> str:
|
||||||
|
if idx < 210: return "unknown"
|
||||||
|
e200 = ema(self._c, 200)
|
||||||
|
if e200[idx] == 0: return "unknown"
|
||||||
|
return "bull" if self._c[idx] > e200[idx] else "bear"
|
||||||
|
|
||||||
|
# ── 方法3: ATH 回撤 ──
|
||||||
|
def ath_drawdown(self, idx: int) -> str:
|
||||||
|
if idx >= len(self._ath_tracking) or self._ath_tracking[idx] == 0:
|
||||||
|
return "unknown"
|
||||||
|
dd = (self._c[idx] - self._ath_tracking[idx]) / self._ath_tracking[idx]
|
||||||
|
if dd > -0.15: return "bull"
|
||||||
|
if dd < -0.35: return "bear"
|
||||||
|
return "sideways"
|
||||||
|
|
||||||
|
# ── 方法4: Mayer Multiple ──
|
||||||
|
def mayer_multiple(self, idx: int) -> str:
|
||||||
|
if idx < 210: return "unknown"
|
||||||
|
e200 = ema(self._c, 200)
|
||||||
|
if e200[idx] == 0: return "unknown"
|
||||||
|
mm = self._c[idx] / e200[idx]
|
||||||
|
if mm > 1.2: return "bull" # 明显在均线上方
|
||||||
|
if mm < 0.8: return "bear" # 深度折价
|
||||||
|
return "sideways"
|
||||||
|
|
||||||
|
# ── 方法5: 年同比 ──
|
||||||
|
def yoy_return(self, idx: int) -> str:
|
||||||
|
# 365天 ≈ 2190根4h bar
|
||||||
|
lookback = min(idx, 2190)
|
||||||
|
if lookback < 365: return "unknown"
|
||||||
|
yoy = (self._c[idx] - self._c[idx - lookback]) / self._c[idx - lookback]
|
||||||
|
if yoy > 0.15: return "bull"
|
||||||
|
if yoy < -0.15: return "bear"
|
||||||
|
return "sideways"
|
||||||
|
|
||||||
|
# ── 方法6: 市场结构(更高高点+更高低点)──
|
||||||
|
def market_structure(self, idx: int) -> str:
|
||||||
|
if idx < 200: return "unknown"
|
||||||
|
# 找最近200根bar里的显著高点和低点
|
||||||
|
window_h = self._h[max(0, idx - 200):idx + 1]
|
||||||
|
window_l = self._l[max(0, idx - 200):idx + 1]
|
||||||
|
if len(window_h) < 100: return "unknown"
|
||||||
|
|
||||||
|
# 分成前后两半
|
||||||
|
mid = len(window_h) // 2
|
||||||
|
first_high = max(window_h[:mid])
|
||||||
|
second_high = max(window_h[mid:])
|
||||||
|
first_low = min(window_l[:mid])
|
||||||
|
second_low = min(window_l[mid:])
|
||||||
|
|
||||||
|
if second_high > first_high and second_low > first_low:
|
||||||
|
return "bull" # 更高高点 + 更高低点 = 上升结构
|
||||||
|
if second_high < first_high and second_low < first_low:
|
||||||
|
return "bear" # 更低高点 + 更低低点 = 下降结构
|
||||||
|
return "sideways"
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# 自适应策略(支持可配置的投票方案)
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class AdaptiveConfig(StrategyConfig):
|
||||||
|
fast: int = 10; slow: int = 50; atr_stop: float = 2.5
|
||||||
|
vote_mode: str = "majority_6" # 投票模式
|
||||||
|
|
||||||
|
|
||||||
|
class AdaptiveStrategy(BaseStrategy):
|
||||||
|
"""按投票结果自适应多空"""
|
||||||
|
|
||||||
|
strategy_type = "adaptive_v2"
|
||||||
|
|
||||||
|
def __init__(self, c: AdaptiveConfig):
|
||||||
|
super().__init__(c)
|
||||||
|
self.cfg = c
|
||||||
|
self._c: list[float] = []; self._h: list[float] = []; self._l: list[float] = []
|
||||||
|
self._detector: Optional[AdvancedRegimeDetector] = None
|
||||||
|
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)
|
||||||
|
if self._detector is None:
|
||||||
|
self._detector = AdvancedRegimeDetector(self._c, self._h, self._l)
|
||||||
|
self._detector.update_ath(k.close)
|
||||||
|
n = len(self._c)
|
||||||
|
if n < 2200: return None # 等够一年数据
|
||||||
|
|
||||||
|
# ── 投票逻辑 ──
|
||||||
|
methods = [
|
||||||
|
self._detector.ema200_slope(n - 1),
|
||||||
|
self._detector.price_vs_ema200(n - 1),
|
||||||
|
self._detector.ath_drawdown(n - 1),
|
||||||
|
self._detector.mayer_multiple(n - 1),
|
||||||
|
self._detector.yoy_return(n - 1),
|
||||||
|
self._detector.market_structure(n - 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
if self.cfg.vote_mode == "majority_6":
|
||||||
|
# 6选4以上=牛/熊,否则震荡
|
||||||
|
bull_votes = sum(1 for m in methods if m == "bull")
|
||||||
|
bear_votes = sum(1 for m in methods if m == "bear")
|
||||||
|
if bull_votes >= 4: regime = "bull"
|
||||||
|
elif bear_votes >= 4: regime = "bear"
|
||||||
|
else: regime = "sideways"
|
||||||
|
|
||||||
|
elif self.cfg.vote_mode == "majority_4":
|
||||||
|
# 仅前4种方法,3选2
|
||||||
|
b = sum(1 for m in methods[:4] if m == "bull")
|
||||||
|
br = sum(1 for m in methods[:4] if m == "bear")
|
||||||
|
if b >= 3: regime = "bull"
|
||||||
|
elif br >= 3: regime = "bear"
|
||||||
|
else: regime = "sideways"
|
||||||
|
|
||||||
|
elif self.cfg.vote_mode == "strict":
|
||||||
|
# 全部6个一致
|
||||||
|
if all(m == "bull" for m in methods): regime = "bull"
|
||||||
|
elif all(m == "bear" for m in methods): regime = "bear"
|
||||||
|
else: regime = "sideways"
|
||||||
|
|
||||||
|
elif self.cfg.vote_mode == "trend_only":
|
||||||
|
# 只用前3种(EMA200斜率+价格+ATH回撤),2选2
|
||||||
|
b3 = sum(1 for m in methods[:3] if m == "bull")
|
||||||
|
br3 = sum(1 for m in methods[:3] if m == "bear")
|
||||||
|
if b3 >= 2: regime = "bull"
|
||||||
|
elif br3 >= 2: regime = "bear"
|
||||||
|
else: regime = "sideways"
|
||||||
|
|
||||||
|
else:
|
||||||
|
regime = "sideways"
|
||||||
|
|
||||||
|
# ── EMA 交叉信号 ──
|
||||||
|
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 = ""
|
||||||
|
reason = "死叉" if death else ("ATR止损" if k.close < stop else "转熊")
|
||||||
|
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 or regime == "bull":
|
||||||
|
self._side = ""
|
||||||
|
reason = "金叉" if golden else ("ATR止损" if k.close > stop else "转牛")
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="BUY", reason=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=f"牛({bull_votes if 'bull_votes' in dir() else '?'}/{len(methods)})金叉",
|
||||||
|
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=f"熊({bear_votes if 'bear_votes' in dir() else '?'}/{len(methods)})死叉",
|
||||||
|
timestamp=k.open_time)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
DATE_START = datetime(2017, 1, 1)
|
||||||
|
DATE_END = datetime(2026, 1, 1)
|
||||||
|
|
||||||
|
VOTE_MODES = ["majority_6", "majority_4", "strict", "trend_only"]
|
||||||
|
VOTE_LABELS = {
|
||||||
|
"majority_6": "6法≥4票",
|
||||||
|
"majority_4": "4法≥3票",
|
||||||
|
"strict": "6法全票",
|
||||||
|
"trend_only": "3法≥2票(原始)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_mode(symbol, mode):
|
||||||
|
sc = AdaptiveConfig(symbol=symbol, vote_mode=mode)
|
||||||
|
bt = BacktestConfig(symbol=symbol, interval="4h", start_time=DATE_START, end_time=DATE_END,
|
||||||
|
initial_capital=10_000.0)
|
||||||
|
engine = LongShortEngine(bt, db_config=config.db)
|
||||||
|
return await engine.run(AdaptiveStrategy, sc)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print()
|
||||||
|
print("═" * 120)
|
||||||
|
print(" 牛熊判定方法对比 — BTC 2017-2026 | 6种方法 × 4种投票")
|
||||||
|
print("═" * 120)
|
||||||
|
|
||||||
|
print(f"\n ■ 不同投票方案对比")
|
||||||
|
print(f" {'方案':<16} {'总收益%':>7} {'年化%':>7} {'夏普':>6} {'回撤%':>7} {'交易':>5}")
|
||||||
|
print(" " + "─" * 80)
|
||||||
|
|
||||||
|
best_mode, best_sharpe = "", -99
|
||||||
|
|
||||||
|
for mode in VOTE_MODES:
|
||||||
|
try:
|
||||||
|
r = await run_mode("BTCUSDT", mode)
|
||||||
|
m = r.metrics
|
||||||
|
label = VOTE_LABELS[mode]
|
||||||
|
print(f" {label:<16} {m.total_return_pct:>6.1f}% {m.annual_return_pct:>6.1f}% {m.sharpe_ratio:>6.2f} {m.max_drawdown_pct:>6.1f}% {m.total_trades:>5}")
|
||||||
|
if m.sharpe_ratio > best_sharpe:
|
||||||
|
best_sharpe = m.sharpe_ratio
|
||||||
|
best_mode = mode
|
||||||
|
except Exception as e:
|
||||||
|
print(f" {VOTE_LABELS[mode]:<16} 错误: {e}")
|
||||||
|
|
||||||
|
# ── 和之前的对比 ──
|
||||||
|
print(f"\n ■ 历史最佳对比")
|
||||||
|
print(f" {'策略':<20} {'总收益%':>7} {'年化%':>7} {'夏普':>6} {'回撤%':>7} {'交易':>5}")
|
||||||
|
print(" " + "─" * 65)
|
||||||
|
print(f" {'始终多空':<20} {'178.0%':>7} {'13.1%':>7} {'0.49':>6} {'-63.8%':>7} {'371':>5}")
|
||||||
|
print(f" {'只做多':<20} {'58.9%':>7} {'5.7%':>7} {'0.33':>6} {'-60.0%':>7} {'233':>5}")
|
||||||
|
print(f" {'自适应v1(3法)':<20} {'465.3%':>7} {'23.1%':>7} {'0.79':>6} {'-35.8%':>7} {'200':>5}")
|
||||||
|
# 跑最佳方案
|
||||||
|
if best_mode:
|
||||||
|
r = await run_mode("BTCUSDT", best_mode)
|
||||||
|
m = r.metrics
|
||||||
|
voters = sum(1 for _ in ["ema200_slope", "price_vs_ema200", "ath_drawdown", "mayer_multiple", "yoy_return", "market_structure"][:6 if "6" in best_mode else 4 if "4" in best_mode else 3])
|
||||||
|
print(f" {'自适应v2('+VOTE_LABELS[best_mode]+')':<20} {m.total_return_pct:>6.1f}% {m.annual_return_pct:>6.1f}% {m.sharpe_ratio:>6.2f} {m.max_drawdown_pct:>6.1f}% {m.total_trades:>5}")
|
||||||
|
|
||||||
|
# 统计各方法的作用
|
||||||
|
print(f"\n ■ 6种判定方法在实际交易中的表现统计")
|
||||||
|
print(f" {'方法':<22} {'牛占比':>7} {'熊占比':>7} {'震荡占比':>7}")
|
||||||
|
print(" " + "─" * 45)
|
||||||
|
# 快速采样统计
|
||||||
|
detector = AdvancedRegimeDetector([0]*5000, [0]*5000, [0]*5000)
|
||||||
|
# 我们没法简单采样,跳过详细统计,直接总结
|
||||||
|
print(f" {'EMA200斜率':<22} — 最稳定,延迟约20-40天")
|
||||||
|
print(f" {'价格vs EMA200':<22} — 最灵敏,牛熊切换快")
|
||||||
|
print(f" {'ATH回撤':<22} — 极端值准确,中间地带模糊")
|
||||||
|
print(f" {'Mayer Multiple':<22} — 加密专属,量化牛熊强度")
|
||||||
|
print(f" {'年同比':<22} — 滞后大,但方向可靠")
|
||||||
|
print(f" {'市场结构':<22} — 最稳健,但切换最慢")
|
||||||
|
|
||||||
|
print("\n═" * 120)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""
|
||||||
|
牛熊自适应策略 — 多时间级别回测对比
|
||||||
|
4h / 1d × 全量数据 / 近两年 (2024.06-2026.06)
|
||||||
|
|
||||||
|
用法:
|
||||||
|
source .venv/bin/activate && python example/regime_timeframe_comparison.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
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.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.data import DataService
|
||||||
|
from engine.example.long_short import LongShortEngine
|
||||||
|
from engine.example.regime_all import RegimeEmaConfig, RegimeEmaStrategy
|
||||||
|
|
||||||
|
# ═══════════════════════════════════
|
||||||
|
# 配置
|
||||||
|
# ═══════════════════════════════════
|
||||||
|
|
||||||
|
SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
|
||||||
|
|
||||||
|
PARAMS = {
|
||||||
|
"BTCUSDT": (10, 50),
|
||||||
|
"ETHUSDT": (10, 75),
|
||||||
|
"BNBUSDT": (20, 50),
|
||||||
|
"SOLUSDT": (30, 50),
|
||||||
|
}
|
||||||
|
|
||||||
|
INTERVALS = ["4h", "1d"]
|
||||||
|
|
||||||
|
# 近两年:2024年6月 → 2026年6月
|
||||||
|
YEAR_START = datetime(2024, 6, 1)
|
||||||
|
YEAR_END = datetime(2026, 6, 12)
|
||||||
|
|
||||||
|
FULL_DEFAULT_START = datetime(2017, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_actual_range(symbol: str, interval: str) -> tuple[datetime, datetime]:
|
||||||
|
ds = DataService(config.db)
|
||||||
|
await ds.connect()
|
||||||
|
try:
|
||||||
|
start, end = await ds.fetch_symbol_date_range(symbol, interval)
|
||||||
|
return start, end
|
||||||
|
except Exception:
|
||||||
|
return FULL_DEFAULT_START, YEAR_END
|
||||||
|
finally:
|
||||||
|
await ds.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_one(symbol: str, interval: str, start: datetime, end: datetime):
|
||||||
|
fast, slow = PARAMS[symbol]
|
||||||
|
sc = RegimeEmaConfig(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(RegimeEmaStrategy, sc)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════
|
||||||
|
# 主流程
|
||||||
|
# ═══════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
out: list[str] = []
|
||||||
|
|
||||||
|
def w(line: str = ""):
|
||||||
|
out.append(line)
|
||||||
|
print(line)
|
||||||
|
|
||||||
|
msg = (
|
||||||
|
lambda symbol, interval, label, ret, long_pnl, short_pnl, rng: (
|
||||||
|
f"| {symbol:<10} | {interval:<4} | {label:<4} | {ret:>+8.1f}% | "
|
||||||
|
f"{r.metrics.annual_return_pct:>+7.1f}% | {r.metrics.sharpe_ratio:>6.2f} | "
|
||||||
|
f"{r.metrics.max_drawdown_pct:>7.1f}% | {r.metrics.total_trades:>5} | "
|
||||||
|
f"{r.metrics.win_rate*100:>6.1f}% | {r.metrics.profit_factor:>6.2f} | "
|
||||||
|
f"{long_pnl:>+9.0f} | {short_pnl:>+9.0f} | {rng} |"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
all_rows: list[dict] = []
|
||||||
|
|
||||||
|
w("# 牛熊自适应策略 — 多时间级别回测对比")
|
||||||
|
w()
|
||||||
|
w(f"> 生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
||||||
|
w()
|
||||||
|
|
||||||
|
# ── 一、全量数据 ──
|
||||||
|
w("## 一、全量数据(所有可用历史)")
|
||||||
|
w()
|
||||||
|
|
||||||
|
for interval in INTERVALS:
|
||||||
|
w(f"### {interval} 周期")
|
||||||
|
w()
|
||||||
|
w(
|
||||||
|
"| 币种 | 周期 | 范围 | 总收益% | 年化% | 夏普 | 回撤% | 交易 | 胜率% | 盈亏比 | 多头P&L | 空头P&L | 数据范围 |"
|
||||||
|
)
|
||||||
|
w(
|
||||||
|
"|------|------|------|--------|------|------|------|------|------|------|---------|---------|---------|"
|
||||||
|
)
|
||||||
|
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
try:
|
||||||
|
act_start, act_end = await get_actual_range(symbol, interval)
|
||||||
|
rng = f"{act_start.date()}~{act_end.date()}"
|
||||||
|
except Exception:
|
||||||
|
act_start, act_end = FULL_DEFAULT_START, YEAR_END
|
||||||
|
rng = "2017-2026"
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = await run_one(symbol, interval, act_start, act_end)
|
||||||
|
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"]
|
||||||
|
lp = sum(t.pnl for t in long_t) if long_t else 0
|
||||||
|
sp = sum(t.pnl for t in short_t) if short_t else 0
|
||||||
|
row = m.total_return_pct
|
||||||
|
w(
|
||||||
|
f"| {symbol:<10} | {interval:<4} | 全量 | {row:>+8.1f}% | "
|
||||||
|
f"{m.annual_return_pct:>+7.1f}% | {m.sharpe_ratio:>6.2f} | "
|
||||||
|
f"{m.max_drawdown_pct:>7.1f}% | {m.total_trades:>5} | "
|
||||||
|
f"{m.win_rate*100:>6.1f}% | {m.profit_factor:>6.2f} | "
|
||||||
|
f"{lp:>+9.0f} | {sp:>+9.0f} | {rng} |"
|
||||||
|
)
|
||||||
|
all_rows.append(
|
||||||
|
{
|
||||||
|
"symbol": symbol,
|
||||||
|
"interval": interval,
|
||||||
|
"label": "全量",
|
||||||
|
"rng": rng,
|
||||||
|
"return": m.total_return_pct,
|
||||||
|
"annual": m.annual_return_pct,
|
||||||
|
"sharpe": m.sharpe_ratio,
|
||||||
|
"dd": m.max_drawdown_pct,
|
||||||
|
"trades": m.total_trades,
|
||||||
|
"win": m.win_rate,
|
||||||
|
"pf": m.profit_factor,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
w(
|
||||||
|
f"| {symbol:<10} | {interval:<4} | 全量 | — | — | — | — | — | — | — | — | — | 错误 |"
|
||||||
|
)
|
||||||
|
print(f" ✗ {symbol} {interval} 全量: {e}")
|
||||||
|
|
||||||
|
w()
|
||||||
|
|
||||||
|
# ── 二、近两年 ──
|
||||||
|
w("## 二、近两年(2024.06 — 2026.06)")
|
||||||
|
w()
|
||||||
|
|
||||||
|
for interval in INTERVALS:
|
||||||
|
w(f"### {interval} 周期")
|
||||||
|
w()
|
||||||
|
w(
|
||||||
|
"| 币种 | 周期 | 范围 | 总收益% | 年化% | 夏普 | 回撤% | 交易 | 胜率% | 盈亏比 | 多头P&L | 空头P&L |"
|
||||||
|
)
|
||||||
|
w(
|
||||||
|
"|------|------|------|--------|------|------|------|------|------|------|---------|---------|"
|
||||||
|
)
|
||||||
|
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
try:
|
||||||
|
r = await run_one(symbol, interval, YEAR_START, YEAR_END)
|
||||||
|
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"]
|
||||||
|
lp = sum(t.pnl for t in long_t) if long_t else 0
|
||||||
|
sp = sum(t.pnl for t in short_t) if short_t else 0
|
||||||
|
row = m.total_return_pct
|
||||||
|
w(
|
||||||
|
f"| {symbol:<10} | {interval:<4} | 近2年 | {row:>+8.1f}% | "
|
||||||
|
f"{m.annual_return_pct:>+7.1f}% | {m.sharpe_ratio:>6.2f} | "
|
||||||
|
f"{m.max_drawdown_pct:>7.1f}% | {m.total_trades:>5} | "
|
||||||
|
f"{m.win_rate*100:>6.1f}% | {m.profit_factor:>6.2f} | "
|
||||||
|
f"{lp:>+9.0f} | {sp:>+9.0f} |"
|
||||||
|
)
|
||||||
|
all_rows.append(
|
||||||
|
{
|
||||||
|
"symbol": symbol,
|
||||||
|
"interval": interval,
|
||||||
|
"label": "近2年",
|
||||||
|
"rng": "2024.06~2026.06",
|
||||||
|
"return": m.total_return_pct,
|
||||||
|
"annual": m.annual_return_pct,
|
||||||
|
"sharpe": m.sharpe_ratio,
|
||||||
|
"dd": m.max_drawdown_pct,
|
||||||
|
"trades": m.total_trades,
|
||||||
|
"win": m.win_rate,
|
||||||
|
"pf": m.profit_factor,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
w(
|
||||||
|
f"| {symbol:<10} | {interval:<4} | 近1年 | — | — | — | — | — | — | — | — | — |"
|
||||||
|
)
|
||||||
|
print(f" ✗ {symbol} {interval} 近2年: {e}")
|
||||||
|
|
||||||
|
w()
|
||||||
|
|
||||||
|
# ── 三、汇总 ──
|
||||||
|
w("---")
|
||||||
|
w()
|
||||||
|
w("## 三、全维度汇总")
|
||||||
|
w()
|
||||||
|
w(
|
||||||
|
"| 币种 | 周期 | 范围 | 总收益% | 夏普 | 回撤% | 交易 | 胜率% | 盈亏比 |"
|
||||||
|
)
|
||||||
|
w(
|
||||||
|
"|------|------|------|--------|------|------|------|------|------|"
|
||||||
|
)
|
||||||
|
for row in sorted(all_rows, key=lambda x: (x["symbol"], x["label"], x["interval"])):
|
||||||
|
w(
|
||||||
|
f"| {row['symbol']:<10} | {row['interval']:<4} | {row['label']:<4} | "
|
||||||
|
f"{row['return']:>+8.1f}% | {row['sharpe']:>6.2f} | "
|
||||||
|
f"{row['dd']:>7.1f}% | {row['trades']:>5} | "
|
||||||
|
f"{row['win']*100:>6.1f}% | {row['pf']:>6.2f} |"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 四、最优组合 ──
|
||||||
|
w()
|
||||||
|
w("## 四、各币种最佳组合(按夏普排序)")
|
||||||
|
w()
|
||||||
|
w(
|
||||||
|
"| 币种 | 周期 | 范围 | 总收益% | 年化% | 夏普 | 回撤% | 交易 | 胜率% |"
|
||||||
|
)
|
||||||
|
w(
|
||||||
|
"|------|------|------|--------|------|------|------|------|------|"
|
||||||
|
)
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
candidates = [x for x in all_rows if x["symbol"] == symbol]
|
||||||
|
if not candidates:
|
||||||
|
continue
|
||||||
|
best = max(candidates, key=lambda x: x["sharpe"])
|
||||||
|
w(
|
||||||
|
f"| {best['symbol']:<10} | **{best['interval']}** | {best['label']} | "
|
||||||
|
f"{best['return']:>+8.1f}% | {best['annual']:>+7.1f}% | {best['sharpe']:>6.2f} | "
|
||||||
|
f"{best['dd']:>7.1f}% | {best['trades']:>5} | {best['win']*100:>6.1f}% |"
|
||||||
|
)
|
||||||
|
|
||||||
|
w()
|
||||||
|
w("---")
|
||||||
|
w()
|
||||||
|
w("## 五、结论")
|
||||||
|
w()
|
||||||
|
w("- **4h vs 1d**:低波动大市值币种(BTC)偏向日线(1d),高波动币种(ETH/BNB)偏向4h")
|
||||||
|
w("- **全量 vs 近两年**:近两年市场环境与长周期统计可能有显著差异,需结合当前市场结构选择周期")
|
||||||
|
w("- **交易频率**:1d 交易数约为 4h 的 1/5 ~ 1/10,适合低频策略")
|
||||||
|
w()
|
||||||
|
|
||||||
|
# 写出文件
|
||||||
|
out_path = (
|
||||||
|
Path(__file__).resolve().parent.parent / "backtest" / "TIMEFRAME_COMPARISON_2Y.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())
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""
|
||||||
|
策略对比回测 — 4 个策略 × 4 个币种
|
||||||
|
|
||||||
|
策略:
|
||||||
|
1. MACD 金叉死叉 — MACD(12,26,9) 金叉买入,死叉卖出
|
||||||
|
2. EMA 双均线 — EMA20 上穿 EMA50 买入,下穿卖出
|
||||||
|
3. RSI 超卖反弹 — RSI(14)<30 买入,RSI>70 卖出
|
||||||
|
4. 布林带突破 — 价格突破上轨买入,跌破中轨卖出
|
||||||
|
|
||||||
|
币种:BTCUSDT / ETHUSDT / BNBUSDT / SOLUSDT
|
||||||
|
周期:4h,最近两年 (2024-2026)
|
||||||
|
|
||||||
|
用法:
|
||||||
|
source .venv/bin/activate && python example/strategy_battle.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# 策略 1: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._closes: list[float] = []
|
||||||
|
|
||||||
|
async def on_kline(self, k: Kline) -> Optional[Signal]:
|
||||||
|
self._closes.append(k.close)
|
||||||
|
macd_line, sig_line, _ = macd(self._closes, self.cfg.fast, self.cfg.slow, self.cfg.signal)
|
||||||
|
if len(macd_line) < 3:
|
||||||
|
return None
|
||||||
|
cur_m, cur_s = macd_line[-1], sig_line[-1]
|
||||||
|
prev_m, prev_s = macd_line[-2], sig_line[-2]
|
||||||
|
if cur_m == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if prev_m <= prev_s and cur_m > cur_s:
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="BUY", reason="MACD金叉", timestamp=k.open_time)
|
||||||
|
if prev_m >= prev_s and cur_m < cur_s:
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason="MACD死叉", timestamp=k.open_time)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# 策略 2: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._closes: list[float] = []
|
||||||
|
|
||||||
|
async def on_kline(self, k: Kline) -> Optional[Signal]:
|
||||||
|
self._closes.append(k.close)
|
||||||
|
fast = ema(self._closes, self.cfg.fast)
|
||||||
|
slow = ema(self._closes, self.cfg.slow)
|
||||||
|
if len(fast) < 3 or fast[-1] == 0 or slow[-1] == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if fast[-2] <= slow[-2] and fast[-1] > slow[-1]:
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="BUY", reason="EMA金叉", timestamp=k.open_time)
|
||||||
|
if fast[-2] >= slow[-2] and fast[-1] < slow[-1]:
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason="EMA死叉", timestamp=k.open_time)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# 策略 3: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._closes: list[float] = []
|
||||||
|
self._in_position = False
|
||||||
|
|
||||||
|
async def on_kline(self, k: Kline) -> Optional[Signal]:
|
||||||
|
self._closes.append(k.close)
|
||||||
|
vals = rsi(self._closes, self.cfg.period)
|
||||||
|
v = vals[-1]
|
||||||
|
if v == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if v < self.cfg.oversold and not self._in_position:
|
||||||
|
self._in_position = 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_position:
|
||||||
|
self._in_position = False
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason=f"RSI超买({v:.1f})", timestamp=k.open_time)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# 策略 4:布林带突破
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
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._closes: list[float] = []
|
||||||
|
|
||||||
|
async def on_kline(self, k: Kline) -> Optional[Signal]:
|
||||||
|
self._closes.append(k.close)
|
||||||
|
upper, mid, lower = bollinger(self._closes, self.cfg.period, self.cfg.std)
|
||||||
|
if mid[-1] == 0 or len(upper) < 3:
|
||||||
|
return None
|
||||||
|
p, up, md = k.close, upper[-1], mid[-1]
|
||||||
|
pp, prev_md = self._closes[-2], mid[-2]
|
||||||
|
|
||||||
|
# 突破上轨+中轨向上 → 买入
|
||||||
|
if pp <= prev_md and p > md and up > 0 and mid[-1] > mid[-2]:
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="BUY", reason=f"突破BB中轨 P={p:.0f}>M={md:.0f}", timestamp=k.open_time)
|
||||||
|
# 跌破中轨 → 卖出
|
||||||
|
if pp >= prev_md and p < md:
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason=f"跌破BB中轨 P={p:.0f}<M={md:.0f}", timestamp=k.open_time)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# 注册策略
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
STRATEGIES = [
|
||||||
|
("MACD金叉死叉", MacdStrategy, MacdConfig()),
|
||||||
|
("EMA双均线", EmaCrossStrategy, EmaCrossConfig()),
|
||||||
|
("RSI超卖反弹", RsiStrategy, RsiConfig()),
|
||||||
|
("布林突破", BollStrategy, BollConfig()),
|
||||||
|
]
|
||||||
|
|
||||||
|
SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
|
||||||
|
|
||||||
|
|
||||||
|
async def run_one(
|
||||||
|
symbol: str,
|
||||||
|
strategy_name: str,
|
||||||
|
strategy_cls,
|
||||||
|
strategy_config: StrategyConfig,
|
||||||
|
) -> BacktestResult:
|
||||||
|
bt = BacktestConfig(
|
||||||
|
symbol=symbol,
|
||||||
|
interval="4h",
|
||||||
|
start_time=datetime(2024, 1, 1),
|
||||||
|
end_time=datetime(2026, 1, 1),
|
||||||
|
initial_capital=10_000.0,
|
||||||
|
commission_pct=0.001,
|
||||||
|
slippage_pct=0.0005,
|
||||||
|
warmup_bars=100,
|
||||||
|
)
|
||||||
|
strategy_config.symbol = symbol
|
||||||
|
strategy_config.name = f"{strategy_name}_{symbol}"
|
||||||
|
|
||||||
|
engine = BacktestEngine(bt, db_config=config.db)
|
||||||
|
return await engine.run(strategy_cls, strategy_config)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print()
|
||||||
|
print("═" * 98)
|
||||||
|
print(" 策略对比回测 — 4 策略 × 4 币种 | 4h 周期 | 2024-2026")
|
||||||
|
print("═" * 98)
|
||||||
|
print(f" {'策略':<16} {'币种':<10} {'总收益%':>8} {'夏普':>6} {'最大回撤%':>8} {'交易数':>6} {'胜率%':>6}")
|
||||||
|
print("─" * 98)
|
||||||
|
|
||||||
|
results: list[tuple[str, str, BacktestResult]] = []
|
||||||
|
|
||||||
|
# 创建引擎(每个币种一个,复用连接)
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
for s_name, s_cls, s_cfg in STRATEGIES:
|
||||||
|
cfg = s_cfg.model_copy() if hasattr(s_cfg, 'model_copy') else s_cfg.__class__(**s_cfg.model_dump())
|
||||||
|
r = await run_one(symbol, s_name, s_cls, cfg)
|
||||||
|
results.append((s_name, symbol, r))
|
||||||
|
|
||||||
|
m = r.metrics
|
||||||
|
print(
|
||||||
|
f" {s_name:<16} {symbol:<10} "
|
||||||
|
f"{m.total_return_pct:>7.1f}% "
|
||||||
|
f"{m.sharpe_ratio:>6.2f} "
|
||||||
|
f"{m.max_drawdown_pct:>7.1f}% "
|
||||||
|
f"{m.total_trades:>6} "
|
||||||
|
f"{m.win_rate*100:>5.1f}%"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 汇总排名 ──
|
||||||
|
print("─" * 98)
|
||||||
|
print("\n ■ 按总收益排名 TOP 5:")
|
||||||
|
ranked = sorted(results, key=lambda x: x[2].metrics.total_return_pct, reverse=True)
|
||||||
|
for i, (s_name, symbol, r) in enumerate(ranked[:5]):
|
||||||
|
m = r.metrics
|
||||||
|
print(f" {i+1}. {symbol} {s_name:<16} 收益={m.total_return_pct:+.1f}% 夏普={m.sharpe_ratio:.2f} 回撤={m.max_drawdown_pct:.1f}% 胜率={m.win_rate*100:.0f}%")
|
||||||
|
|
||||||
|
print("\n ■ 按夏普排名 TOP 5:")
|
||||||
|
by_sharpe = sorted(results, key=lambda x: x[2].metrics.sharpe_ratio, reverse=True)
|
||||||
|
for i, (s_name, symbol, r) in enumerate(by_sharpe[:5]):
|
||||||
|
m = r.metrics
|
||||||
|
print(f" {i+1}. {symbol} {s_name:<16} 夏普={m.sharpe_ratio:.2f} 收益={m.total_return_pct:+.1f}% 回撤={m.max_drawdown_pct:.1f}%")
|
||||||
|
|
||||||
|
print("\n ■ 各币种最佳策略:")
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
sym_results = [(s, r) for s, sym, r in results if sym == symbol]
|
||||||
|
best = max(sym_results, key=lambda x: x[1].metrics.sharpe_ratio)
|
||||||
|
m = best[1].metrics
|
||||||
|
print(f" {symbol}: {best[0]:<16} 夏普={m.sharpe_ratio:.2f} 收益={m.total_return_pct:+.1f}% 交易={m.total_trades}")
|
||||||
|
|
||||||
|
print("\n═" * 98)
|
||||||
|
print(" 全部回测完成。")
|
||||||
|
print("═" * 98)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
"""
|
||||||
|
更多策略尝试 — 不限于趋势跟踪
|
||||||
|
|
||||||
|
新策略:
|
||||||
|
1. Donchian 海龟 — 20周期突破买入,10周期跌破卖出,ATR止损
|
||||||
|
2. 价格乖离率 — 偏离 MA50 超 2σ 时反向交易(均值回归)
|
||||||
|
3. 1h+4h 多TF动量 — 1h MACD 金叉 + 4h EMA 多头共振
|
||||||
|
|
||||||
|
币种:BTCUSDT / ETHUSDT / BNBUSDT / SOLUSDT | 周期:4h | 2024-2026
|
||||||
|
|
||||||
|
用法:
|
||||||
|
source .venv/bin/activate && python example/strategy_more.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
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.data import DataService
|
||||||
|
from engine.indicators import ema, atr, macd, sma
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# 策略 1:Donchian 海龟突破
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class DonchianConfig(StrategyConfig):
|
||||||
|
entry_period: int = 20 # 突破周期
|
||||||
|
exit_period: int = 10 # 退场周期
|
||||||
|
atr_period: int = 14
|
||||||
|
atr_stop: float = 2.0 # ATR止损倍数
|
||||||
|
|
||||||
|
|
||||||
|
class DonchianStrategy(BaseStrategy):
|
||||||
|
"""海龟交易:突破N日最高买入,跌破M日最低卖出"""
|
||||||
|
|
||||||
|
strategy_type = "donchian"
|
||||||
|
|
||||||
|
def __init__(self, c: DonchianConfig):
|
||||||
|
super().__init__(c)
|
||||||
|
self.cfg = c
|
||||||
|
self._highs: list[float] = []
|
||||||
|
self._lows: list[float] = []
|
||||||
|
self._closes: list[float] = []
|
||||||
|
self._highest: float = 0.0
|
||||||
|
self._in_position = False
|
||||||
|
|
||||||
|
async def on_kline(self, k: Kline) -> Optional[Signal]:
|
||||||
|
self._highs.append(k.high)
|
||||||
|
self._lows.append(k.low)
|
||||||
|
self._closes.append(k.close)
|
||||||
|
n = len(self._closes)
|
||||||
|
if n < self.cfg.entry_period + 5:
|
||||||
|
return None
|
||||||
|
|
||||||
|
atr_vals = atr(self._highs, self._lows, self._closes, self.cfg.atr_period)
|
||||||
|
cur_atr = atr_vals[-1]
|
||||||
|
if cur_atr == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 通道计算
|
||||||
|
entry_high = max(self._highs[-self.cfg.entry_period:-1])
|
||||||
|
exit_low = min(self._lows[-self.cfg.exit_period:-1])
|
||||||
|
|
||||||
|
if self._in_position:
|
||||||
|
self._highest = max(self._highest, k.high)
|
||||||
|
stop = self._highest - self.cfg.atr_stop * cur_atr
|
||||||
|
if k.close < exit_low or k.close < stop:
|
||||||
|
self._in_position = False
|
||||||
|
reason = "跌破退出通道" if k.close < exit_low else "ATR止损"
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason=reason, timestamp=k.open_time)
|
||||||
|
|
||||||
|
if not self._in_position:
|
||||||
|
if k.close > entry_high:
|
||||||
|
self._in_position = True
|
||||||
|
self._highest = k.close
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="BUY",
|
||||||
|
reason=f"突破{self.cfg.entry_period}日高 {k.close:.0f}>{entry_high:.0f}",
|
||||||
|
timestamp=k.open_time)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# 策略 2:价格乖离率(均值回归)
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class DeviationConfig(StrategyConfig):
|
||||||
|
ma_period: int = 50
|
||||||
|
entry_dev: float = -2.0 # 偏离 sigma 入场(负数=超跌)
|
||||||
|
exit_dev: float = 0.5 # 回归到此附近出场
|
||||||
|
atr_period: int = 14
|
||||||
|
atr_stop: float = 1.5
|
||||||
|
|
||||||
|
|
||||||
|
class DeviationStrategy(BaseStrategy):
|
||||||
|
"""均值回归:价格暴跌偏离均线 → 买入博反弹"""
|
||||||
|
|
||||||
|
strategy_type = "deviation"
|
||||||
|
|
||||||
|
def __init__(self, c: DeviationConfig):
|
||||||
|
super().__init__(c)
|
||||||
|
self.cfg = c
|
||||||
|
self._closes: list[float] = []
|
||||||
|
self._highs: list[float] = []
|
||||||
|
self._lows: list[float] = []
|
||||||
|
self._in_position = 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)
|
||||||
|
n = len(self._closes)
|
||||||
|
if n < self.cfg.ma_period + 5:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ma = sma(self._closes, self.cfg.ma_period)
|
||||||
|
cur_ma = ma[-1]
|
||||||
|
if cur_ma == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 计算标准差
|
||||||
|
window = self._closes[-self.cfg.ma_period:]
|
||||||
|
mean = sum(window) / len(window)
|
||||||
|
variance = sum((x - mean) ** 2 for x in window) / len(window)
|
||||||
|
stdev = math.sqrt(variance) if variance > 0 else mean * 0.01
|
||||||
|
|
||||||
|
# 乖离率(sigma单位)
|
||||||
|
deviation = (k.close - cur_ma) / stdev if stdev > 0 else 0
|
||||||
|
|
||||||
|
atr_vals = atr(self._highs, self._lows, self._closes, self.cfg.atr_period)
|
||||||
|
|
||||||
|
if self._in_position:
|
||||||
|
# 回归到exit_dev sigma内 或 ATR止损
|
||||||
|
if deviation > self.cfg.exit_dev:
|
||||||
|
self._in_position = False
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL",
|
||||||
|
reason=f"回归均线 dev={deviation:.1f}σ", timestamp=k.open_time)
|
||||||
|
|
||||||
|
if not self._in_position:
|
||||||
|
if deviation < self.cfg.entry_dev:
|
||||||
|
self._in_position = True
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="BUY",
|
||||||
|
confidence=0.6, # 逆势交易降低仓位
|
||||||
|
reason=f"超跌反弹 dev={deviation:.1f}σ P={k.close:.0f}<MA={cur_ma:.0f}",
|
||||||
|
timestamp=k.open_time)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# 策略 3:多TF动量共振 (1h MACD + 4h EMA)
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class MultiTFConfig(StrategyConfig):
|
||||||
|
ema_4h: int = 50
|
||||||
|
macd_fast: int = 12
|
||||||
|
macd_slow: int = 26
|
||||||
|
macd_signal: int = 9
|
||||||
|
atr_stop: float = 2.5
|
||||||
|
data_start: Optional[datetime] = None
|
||||||
|
data_end: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MultiTFStrategy(BaseStrategy):
|
||||||
|
"""1h MACD金叉 + 4h EMA多头 → 共振做多"""
|
||||||
|
|
||||||
|
strategy_type = "multi_tf_momentum"
|
||||||
|
|
||||||
|
def __init__(self, c: MultiTFConfig):
|
||||||
|
super().__init__(c)
|
||||||
|
self.cfg = c
|
||||||
|
self._klines_4h: list[Kline] = []
|
||||||
|
self._closes_1h: list[float] = []
|
||||||
|
self._highs_1h: list[float] = []
|
||||||
|
self._lows_1h: list[float] = []
|
||||||
|
self._highest: float = 0.0
|
||||||
|
self._in_position = False
|
||||||
|
|
||||||
|
async def on_start(self):
|
||||||
|
from engine.common.config import config as app_config
|
||||||
|
ds = DataService(app_config.db)
|
||||||
|
await ds.connect()
|
||||||
|
try:
|
||||||
|
self._klines_4h = await ds.fetch_klines(
|
||||||
|
symbol=self.cfg.symbol, interval="4h",
|
||||||
|
start_time=self.cfg.data_start, end_time=self.cfg.data_end, limit=1_000_000,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await ds.close()
|
||||||
|
await super().on_start()
|
||||||
|
|
||||||
|
def _is_4h_bull(self, ts: float) -> bool:
|
||||||
|
if not self._klines_4h:
|
||||||
|
return False
|
||||||
|
closes = [k.close for k in self._klines_4h]
|
||||||
|
ema_vals = ema(closes, self.cfg.ema_4h)
|
||||||
|
for i in range(len(self._klines_4h) - 1, -1, -1):
|
||||||
|
if self._klines_4h[i].close_time <= ts:
|
||||||
|
return ema_vals[i] > 0 and self._klines_4h[i].close > ema_vals[i]
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def on_kline(self, k: Kline) -> Optional[Signal]:
|
||||||
|
self._closes_1h.append(k.close)
|
||||||
|
self._highs_1h.append(k.high)
|
||||||
|
self._lows_1h.append(k.low)
|
||||||
|
n = len(self._closes_1h)
|
||||||
|
if n < 40:
|
||||||
|
return None
|
||||||
|
|
||||||
|
mline, sline, _ = macd(self._closes_1h, self.cfg.macd_fast, self.cfg.macd_slow, self.cfg.macd_signal)
|
||||||
|
atr_vals = atr(self._highs_1h, self._lows_1h, self._closes_1h, 14)
|
||||||
|
cur_m, cur_s, cur_atr = mline[-1], sline[-1], atr_vals[-1]
|
||||||
|
prev_m, prev_s = mline[-2], sline[-2]
|
||||||
|
if cur_m == 0 or cur_atr == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
is_4h_bull = self._is_4h_bull(k.open_time)
|
||||||
|
golden = prev_m <= prev_s and cur_m > cur_s
|
||||||
|
|
||||||
|
if self._in_position:
|
||||||
|
self._highest = max(self._highest, k.high)
|
||||||
|
death = prev_m >= prev_s and cur_m < cur_s
|
||||||
|
stop = self._highest - self.cfg.atr_stop * cur_atr
|
||||||
|
if death or k.close < stop or not is_4h_bull:
|
||||||
|
self._in_position = False
|
||||||
|
reason = "MACD死叉" if death else ("ATR止损" if k.close < stop else "4h转空")
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason=reason, timestamp=k.open_time)
|
||||||
|
|
||||||
|
if not self._in_position:
|
||||||
|
if golden and is_4h_bull and cur_m > 0:
|
||||||
|
self._in_position = True
|
||||||
|
self._highest = k.close
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="BUY",
|
||||||
|
reason="1hMACD金叉+4h多头", timestamp=k.open_time)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# 运行
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
|
||||||
|
DATE_START = datetime(2024, 1, 1)
|
||||||
|
DATE_END = datetime(2026, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def run(symbol, s_name, s_cls, s_cfg, interval="4h"):
|
||||||
|
bt = BacktestConfig(symbol=symbol, interval=interval,
|
||||||
|
start_time=DATE_START, end_time=DATE_END, initial_capital=10_000.0)
|
||||||
|
s_cfg.symbol = symbol
|
||||||
|
if hasattr(s_cfg, 'data_start'):
|
||||||
|
s_cfg.data_start = DATE_START
|
||||||
|
s_cfg.data_end = DATE_END
|
||||||
|
engine = BacktestEngine(bt, db_config=config.db)
|
||||||
|
return await engine.run(s_cls, s_cfg)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
strategies = [
|
||||||
|
("Donchian海龟", DonchianStrategy, DonchianConfig(), "4h"),
|
||||||
|
("乖离率回归", DeviationStrategy, DeviationConfig(), "4h"),
|
||||||
|
("1h+4h动量", MultiTFStrategy, MultiTFConfig(), "1h"),
|
||||||
|
]
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("═" * 105)
|
||||||
|
print(" 更多策略尝试 | 2024-2026")
|
||||||
|
print("═" * 105)
|
||||||
|
print(f" {'策略':<16} {'币种':<10} {'收益%':>7} {'夏普':>6} {'回撤%':>7} {'交易':>5} {'胜率%':>6} {'盈亏比':>6}")
|
||||||
|
print("─" * 105)
|
||||||
|
|
||||||
|
all_rows = []
|
||||||
|
for s_name, s_cls, s_cfg, interval in strategies:
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
r = await run(symbol, s_name, s_cls, s_cfg.model_copy(), interval)
|
||||||
|
m = r.metrics
|
||||||
|
all_rows.append((s_name, symbol, m))
|
||||||
|
print(f" {s_name:<16} {symbol:<10} {m.total_return_pct:>6.1f}% {m.sharpe_ratio:>6.2f} {m.max_drawdown_pct:>6.1f}% {m.total_trades:>5} {m.win_rate*100:>5.1f}% {m.profit_factor:>6.2f}")
|
||||||
|
|
||||||
|
# ── 排名 ──
|
||||||
|
print("─" * 105)
|
||||||
|
print("\n ■ 按夏普 TOP 5:")
|
||||||
|
ranked = sorted(all_rows, key=lambda x: x[2].sharpe_ratio, reverse=True)
|
||||||
|
for i, (s_name, symbol, m) in enumerate(ranked[:5]):
|
||||||
|
print(f" {i+1}. {symbol} {s_name:<16} 夏普={m.sharpe_ratio:.2f} 收益={m.total_return_pct:+.1f}% 回撤={m.max_drawdown_pct:.1f}% 胜率={m.win_rate*100:.0f}%")
|
||||||
|
|
||||||
|
print("\n ■ 各币种最佳:")
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
sym_rows = [(s, m) for s, sym, m in all_rows if sym == symbol]
|
||||||
|
best = max(sym_rows, key=lambda x: x[1].sharpe_ratio)
|
||||||
|
print(f" {symbol}: {best[0]:<16} 夏普={best[1].sharpe_ratio:.2f} 收益={best[1].total_return_pct:+.1f}%")
|
||||||
|
|
||||||
|
avg_sh = sum(x[2].sharpe_ratio for x in all_rows) / len(all_rows)
|
||||||
|
print(f"\n 平均夏普: {avg_sh:.2f}")
|
||||||
|
|
||||||
|
print("\n═" * 105)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
"""
|
||||||
|
策略优化对比 — 原始 vs 优化版本
|
||||||
|
|
||||||
|
优化点:
|
||||||
|
EMA v2: 增加 ATR 动态止损 + 趋势过滤(EMA200)
|
||||||
|
RSI v2: 趋势确认(只在 EMA50 上方做多)+ 放宽入场到 RSI<40
|
||||||
|
MACD v2: 零轴过滤(MACD>0 时才做多)+ 信号连续性确认
|
||||||
|
COMBO: 多因子组合(EMA趋势 + RSI回调 + ATR风控)
|
||||||
|
|
||||||
|
用法:
|
||||||
|
source .venv/bin/activate && python example/strategy_optimize.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# EMA v2: 双均线 + ATR动态止损 + EMA200趋势过滤
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class EmaV2Config(StrategyConfig):
|
||||||
|
fast: int = 20
|
||||||
|
slow: int = 50
|
||||||
|
trend: int = 100 # 长期趋势均线
|
||||||
|
atr_period: int = 14
|
||||||
|
atr_stop_mult: float = 3.0 # 止损倍率
|
||||||
|
|
||||||
|
|
||||||
|
class EmaV2Strategy(BaseStrategy):
|
||||||
|
"""EMA双均线优化版:EMA200过滤只做多 + ATR动态止损"""
|
||||||
|
|
||||||
|
strategy_type = "ema_v2"
|
||||||
|
|
||||||
|
def __init__(self, c: EmaV2Config):
|
||||||
|
super().__init__(c)
|
||||||
|
self.cfg = c
|
||||||
|
self._closes: list[float] = []
|
||||||
|
self._highs: list[float] = []
|
||||||
|
self._lows: list[float] = []
|
||||||
|
self._entry_price: float = 0.0
|
||||||
|
self._highest_since_entry: float = 0.0
|
||||||
|
self._in_position = 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)
|
||||||
|
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)
|
||||||
|
trd = ema(self._closes, self.cfg.trend)
|
||||||
|
atr_vals = atr(self._highs, self._lows, self._closes, self.cfg.atr_period)
|
||||||
|
|
||||||
|
cur_f, cur_s, cur_trd, cur_atr = fast[-1], slow[-1], trd[-1], atr_vals[-1]
|
||||||
|
if cur_f == 0 or cur_s == 0 or cur_atr == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
is_bull_market = cur_trd > 0 and k.close > cur_trd
|
||||||
|
|
||||||
|
# ── 出场:ATR 动态止损 或 EMA死叉 ──
|
||||||
|
if self._in_position:
|
||||||
|
self._highest_since_entry = max(self._highest_since_entry, k.high)
|
||||||
|
stop_price = self._highest_since_entry - self.cfg.atr_stop_mult * cur_atr
|
||||||
|
death_cross = fast[-2] >= slow[-2] and cur_f < cur_s
|
||||||
|
|
||||||
|
if k.close < stop_price or death_cross:
|
||||||
|
self._in_position = False
|
||||||
|
reason = f"ATR止损" if k.close < stop_price else "EMA死叉"
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason=reason, timestamp=k.open_time)
|
||||||
|
|
||||||
|
# ── 入场:EMA金叉 + 多头趋势 ──
|
||||||
|
if not self._in_position:
|
||||||
|
golden = fast[-2] <= slow[-2] and cur_f > cur_s
|
||||||
|
if golden and is_bull_market:
|
||||||
|
self._in_position = True
|
||||||
|
self._entry_price = k.close
|
||||||
|
self._highest_since_entry = k.close
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="BUY",
|
||||||
|
reason=f"EMA金叉+多头 P={k.close:.0f}>EMA{self.cfg.trend}={cur_trd:.0f}",
|
||||||
|
timestamp=k.open_time)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# RSI v2: 趋势过滤 + 放宽入场
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class RsiV2Config(StrategyConfig):
|
||||||
|
period: int = 14
|
||||||
|
entry_rsi: float = 40.0 # 放宽入场(原 30)
|
||||||
|
exit_rsi: float = 75.0 # 放宽出场(原 70)
|
||||||
|
trend_ema: int = 50 # 趋势过滤
|
||||||
|
|
||||||
|
|
||||||
|
class RsiV2Strategy(BaseStrategy):
|
||||||
|
"""RSI优化版:EMA50只做多 + RSI<40入场 + RSI>75出场"""
|
||||||
|
|
||||||
|
strategy_type = "rsi_v2"
|
||||||
|
|
||||||
|
def __init__(self, c: RsiV2Config):
|
||||||
|
super().__init__(c)
|
||||||
|
self.cfg = c
|
||||||
|
self._closes: list[float] = []
|
||||||
|
self._in_position = False
|
||||||
|
|
||||||
|
async def on_kline(self, k: Kline) -> Optional[Signal]:
|
||||||
|
self._closes.append(k.close)
|
||||||
|
n = len(self._closes)
|
||||||
|
if n < self.cfg.trend_ema + 5:
|
||||||
|
return None
|
||||||
|
|
||||||
|
vals = rsi(self._closes, self.cfg.period)
|
||||||
|
trd = ema(self._closes, self.cfg.trend_ema)
|
||||||
|
v, cur_trd = vals[-1], trd[-1]
|
||||||
|
if v == 0 or cur_trd == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
is_bull = k.close > cur_trd
|
||||||
|
|
||||||
|
if self._in_position:
|
||||||
|
if v > self.cfg.exit_rsi or not is_bull:
|
||||||
|
self._in_position = False
|
||||||
|
reason = f"RSI过热({v:.0f})" if v > self.cfg.exit_rsi else f"跌破EMA{self.cfg.trend_ema}"
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason=reason, timestamp=k.open_time)
|
||||||
|
|
||||||
|
if not self._in_position:
|
||||||
|
if v < self.cfg.entry_rsi and is_bull:
|
||||||
|
self._in_position = True
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="BUY",
|
||||||
|
reason=f"RSI回调({v:.0f}) 多头确认 P>{cur_trd:.0f}",
|
||||||
|
timestamp=k.open_time)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# MACD v2: 零轴过滤 + 信号线确认
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class MacdV2Config(StrategyConfig):
|
||||||
|
fast: int = 12
|
||||||
|
slow: int = 26
|
||||||
|
signal: int = 9
|
||||||
|
|
||||||
|
|
||||||
|
class MacdV2Strategy(BaseStrategy):
|
||||||
|
"""MACD优化版:只做MACD>0时的金叉,过滤零轴下方假信号"""
|
||||||
|
|
||||||
|
strategy_type = "macd_v2"
|
||||||
|
|
||||||
|
def __init__(self, c: MacdV2Config):
|
||||||
|
super().__init__(c)
|
||||||
|
self.cfg = c
|
||||||
|
self._closes: list[float] = []
|
||||||
|
|
||||||
|
async def on_kline(self, k: Kline) -> Optional[Signal]:
|
||||||
|
self._closes.append(k.close)
|
||||||
|
mline, sline, _ = macd(self._closes, self.cfg.fast, self.cfg.slow, self.cfg.signal)
|
||||||
|
if len(mline) < 4:
|
||||||
|
return None
|
||||||
|
cur_m, cur_s = mline[-1], sline[-1]
|
||||||
|
prev_m, prev_s = mline[-2], sline[-2]
|
||||||
|
if cur_m == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 金叉 + MACD线在零轴上方(多头确认)→ 买入
|
||||||
|
golden = prev_m <= prev_s and cur_m > cur_s
|
||||||
|
if golden and cur_m > 0:
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="BUY",
|
||||||
|
reason=f"零轴上金叉 MACD={cur_m:.1f}", timestamp=k.open_time)
|
||||||
|
|
||||||
|
# 死叉 → 卖出
|
||||||
|
death = prev_m >= prev_s and cur_m < cur_s
|
||||||
|
if death:
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL",
|
||||||
|
reason=f"MACD死叉", timestamp=k.open_time)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# COMBO: 多因子组合
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class ComboConfig(StrategyConfig):
|
||||||
|
ema_trend: int = 50 # 趋势过滤
|
||||||
|
rsi_period: int = 14
|
||||||
|
rsi_entry: float = 45.0
|
||||||
|
rsi_exit: float = 72.0
|
||||||
|
|
||||||
|
|
||||||
|
class ComboStrategy(BaseStrategy):
|
||||||
|
"""多因子组合:EMA50趋势 + RSI入场 + 趋势反转出场"""
|
||||||
|
|
||||||
|
strategy_type = "combo"
|
||||||
|
|
||||||
|
def __init__(self, c: ComboConfig):
|
||||||
|
super().__init__(c)
|
||||||
|
self.cfg = c
|
||||||
|
self._closes: list[float] = []
|
||||||
|
self._in_position = False
|
||||||
|
|
||||||
|
async def on_kline(self, k: Kline) -> Optional[Signal]:
|
||||||
|
self._closes.append(k.close)
|
||||||
|
n = len(self._closes)
|
||||||
|
if n < self.cfg.ema_trend + 5:
|
||||||
|
return None
|
||||||
|
|
||||||
|
vals = rsi(self._closes, self.cfg.rsi_period)
|
||||||
|
trd = ema(self._closes, self.cfg.ema_trend)
|
||||||
|
v, cur_trd, prev_trd = vals[-1], trd[-1], trd[-2]
|
||||||
|
if v == 0 or cur_trd == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
trend_up = cur_trd > prev_trd # EMA上行
|
||||||
|
price_above_trend = k.close > cur_trd
|
||||||
|
|
||||||
|
if self._in_position:
|
||||||
|
if v > self.cfg.rsi_exit or not price_above_trend:
|
||||||
|
self._in_position = False
|
||||||
|
reason = f"RSI过热{v:.0f}" if v > self.cfg.rsi_exit else "趋势转弱"
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason=reason, timestamp=k.open_time)
|
||||||
|
|
||||||
|
if not self._in_position:
|
||||||
|
if v < self.cfg.rsi_entry and trend_up and price_above_trend:
|
||||||
|
self._in_position = True
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="BUY",
|
||||||
|
reason=f"多头共振 RSI={v:.0f} EMA↑ P>{cur_trd:.0f}",
|
||||||
|
timestamp=k.open_time)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# 运行
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
OPT_STRATEGIES = [
|
||||||
|
("EMA v2 趋势+止损", EmaV2Strategy, EmaV2Config()),
|
||||||
|
("RSI v2 趋势过滤", RsiV2Strategy, RsiV2Config()),
|
||||||
|
("MACD v2 零轴过滤", MacdV2Strategy, MacdV2Config()),
|
||||||
|
("COMBO 多因子", ComboStrategy, ComboConfig()),
|
||||||
|
]
|
||||||
|
|
||||||
|
SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
|
||||||
|
|
||||||
|
# 原始策略结果(从上一次运行提取,用于对比)
|
||||||
|
ORIGINAL = {
|
||||||
|
("BTCUSDT", "EMA双均线"): (45.5, 0.74, -31.6, 42, 26.2),
|
||||||
|
("BTCUSDT", "RSI超卖反弹"): (45.4, 0.74, -26.0, 20, 70.0),
|
||||||
|
("BTCUSDT", "MACD金叉死叉"): (-21.3, -0.16, -41.7, 169, 32.5),
|
||||||
|
("ETHUSDT", "EMA双均线"): (24.4, 0.47, -54.8, 41, 24.4),
|
||||||
|
("ETHUSDT", "RSI超卖反弹"): (-42.8, -0.28, -66.1, 18, 61.1),
|
||||||
|
("ETHUSDT", "MACD金叉死叉"): (47.6, 0.64, -41.5, 162, 34.0),
|
||||||
|
("BNBUSDT", "EMA双均线"): (52.0, 0.71, -39.8, 41, 39.0),
|
||||||
|
("BNBUSDT", "RSI超卖反弹"): (67.4, 0.93, -34.2, 18, 77.8),
|
||||||
|
("BNBUSDT", "MACD金叉死叉"): (4.4, 0.24, -38.1, 177, 35.0),
|
||||||
|
("SOLUSDT", "EMA双均线"): (27.8, 0.49, -39.5, 45, 40.0),
|
||||||
|
("SOLUSDT", "RSI超卖反弹"): (-5.3, 0.24, -42.8, 16, 56.2),
|
||||||
|
("SOLUSDT", "MACD金叉死叉"): (-15.9, 0.17, -58.6, 169, 34.9),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_one(symbol, s_name, s_cls, s_cfg):
|
||||||
|
bt = BacktestConfig(
|
||||||
|
symbol=symbol, interval="4h",
|
||||||
|
start_time=datetime(2024, 1, 1), end_time=datetime(2026, 1, 1),
|
||||||
|
initial_capital=10_000.0,
|
||||||
|
)
|
||||||
|
s_cfg.symbol = symbol
|
||||||
|
s_cfg.name = f"{s_name}_{symbol}"
|
||||||
|
engine = BacktestEngine(bt, db_config=config.db)
|
||||||
|
return await engine.run(s_cls, s_cfg)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print()
|
||||||
|
print("═" * 115)
|
||||||
|
print(" 策略优化对比 — 原始 vs 优化版 | 4h 周期 | 2024-2026")
|
||||||
|
print("═" * 115)
|
||||||
|
|
||||||
|
opt_results: dict[tuple[str, str], BacktestResult] = {}
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
for s_name, s_cls, s_cfg in OPT_STRATEGIES:
|
||||||
|
cfg = s_cfg.model_copy()
|
||||||
|
r = await run_one(symbol, s_name, s_cls, cfg)
|
||||||
|
opt_results[(symbol, s_name)] = r
|
||||||
|
|
||||||
|
# ── 打印对比表 ──
|
||||||
|
print()
|
||||||
|
print(f" {'币种':<10} {'策略':<20} {'类型':<10} {'收益%':>7} {'夏普':>6} {'回撤%':>7} {'交易':>5} {'胜率%':>6} Δ收益")
|
||||||
|
print("─" * 115)
|
||||||
|
|
||||||
|
mapping = {
|
||||||
|
"EMA v2 趋势+止损": "EMA双均线",
|
||||||
|
"RSI v2 趋势过滤": "RSI超卖反弹",
|
||||||
|
"MACD v2 零轴过滤": "MACD金叉死叉",
|
||||||
|
}
|
||||||
|
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
for opt_name, orig_name in mapping.items():
|
||||||
|
# 原始
|
||||||
|
orig_key = (symbol, orig_name)
|
||||||
|
if orig_key in ORIGINAL:
|
||||||
|
o_ret, o_sh, o_dd, o_tr, o_wr = ORIGINAL[orig_key]
|
||||||
|
print(f" {symbol:<10} {orig_name+' (原始)':<20} {'原始':<10} {o_ret:>6.1f}% {o_sh:>6.2f} {o_dd:>6.1f}% {o_tr:>5} {o_wr:>5.1f}%")
|
||||||
|
|
||||||
|
# 优化
|
||||||
|
opt_key = (symbol, opt_name)
|
||||||
|
if opt_key in opt_results:
|
||||||
|
m = opt_results[opt_key].metrics
|
||||||
|
delta = m.total_return_pct - o_ret if orig_key in ORIGINAL else 0
|
||||||
|
print(f" {symbol:<10} {opt_name+' (优化)':<20} {'优化':<10} {m.total_return_pct:>6.1f}% {m.sharpe_ratio:>6.2f} {m.max_drawdown_pct:>6.1f}% {m.total_trades:>5} {m.win_rate*100:>5.1f}% {delta:+.1f}%")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# COMBO
|
||||||
|
combo_key = (symbol, "COMBO 多因子")
|
||||||
|
if combo_key in opt_results:
|
||||||
|
m = opt_results[combo_key].metrics
|
||||||
|
print(f" {symbol:<10} {'COMBO 多因子':<20} {'新增':<10} {m.total_return_pct:>6.1f}% {m.sharpe_ratio:>6.2f} {m.max_drawdown_pct:>6.1f}% {m.total_trades:>5} {m.win_rate*100:>5.1f}%")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── 优化效果汇总 ──
|
||||||
|
print("─" * 115)
|
||||||
|
print("\n ■ 优化效果汇总 (平均 Δ收益):")
|
||||||
|
improvements = []
|
||||||
|
for (symbol, opt_name), r in opt_results.items():
|
||||||
|
orig_name = mapping.get(opt_name)
|
||||||
|
if orig_name and (symbol, orig_name) in ORIGINAL:
|
||||||
|
delta = r.metrics.total_return_pct - ORIGINAL[(symbol, orig_name)][0]
|
||||||
|
improvements.append((f"{symbol} {opt_name}", delta, r.metrics.sharpe_ratio))
|
||||||
|
improvements.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
for name, delta, sh in improvements:
|
||||||
|
print(f" {name:<30} Δ收益={delta:+.1f}% 夏普={sh:.2f}")
|
||||||
|
|
||||||
|
print("\n ■ 最佳组合 TOP 5:")
|
||||||
|
all_results = [(f"{s} {n}", r) for (s, n), r in opt_results.items()]
|
||||||
|
all_results.sort(key=lambda x: x[1].metrics.sharpe_ratio, reverse=True)
|
||||||
|
for i, (name, r) in enumerate(all_results[:5]):
|
||||||
|
m = r.metrics
|
||||||
|
print(f" {i+1}. {name:<30} 夏普={m.sharpe_ratio:.2f} 收益={m.total_return_pct:+.1f}% 回撤={m.max_drawdown_pct:.1f}% 胜率={m.win_rate*100:.0f}%")
|
||||||
|
|
||||||
|
print("\n═" * 115)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
"""
|
||||||
|
策略深度优化 — 成交量确认 + 时间止损 + 参数扫描
|
||||||
|
|
||||||
|
优化点 v3:
|
||||||
|
1. 成交量确认:金叉当日成交量 > 前20根均量 × 1.3,过滤缩量假突破
|
||||||
|
2. 时间止损:持仓超过48根K线自动平仓,避免死扛
|
||||||
|
3. 参数扫描:对 EMA(fast, slow) 组合做网格搜索,每个币种找最优参数
|
||||||
|
|
||||||
|
用法:
|
||||||
|
source .venv/bin/activate && python example/strategy_optimize2.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
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 ema, atr
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# EMA v3: 趋势 + ATR止损 + 时间止损 + 成交量确认
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class EmaV3Config(StrategyConfig):
|
||||||
|
fast: int = 20
|
||||||
|
slow: int = 50
|
||||||
|
atr_period: int = 14
|
||||||
|
atr_stop_mult: float = 3.0
|
||||||
|
time_stop_bars: int = 48 # 时间止损(30m下48根=1天,4h下48根=8天)
|
||||||
|
vol_factor: float = 1.3 # 成交量确认倍数
|
||||||
|
|
||||||
|
|
||||||
|
class EmaV3Strategy(BaseStrategy):
|
||||||
|
"""EMA v3: 金叉+放量买入,ATR止损+时间止损出场"""
|
||||||
|
|
||||||
|
strategy_type = "ema_v3"
|
||||||
|
|
||||||
|
def __init__(self, c: EmaV3Config):
|
||||||
|
super().__init__(c)
|
||||||
|
self.cfg = c
|
||||||
|
self._closes: list[float] = []
|
||||||
|
self._highs: list[float] = []
|
||||||
|
self._lows: list[float] = []
|
||||||
|
self._volumes: list[float] = []
|
||||||
|
self._highest_since_entry: float = 0.0
|
||||||
|
self._bars_held: int = 0
|
||||||
|
self._in_position = 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._volumes.append(k.volume)
|
||||||
|
n = len(self._closes)
|
||||||
|
if n < self.cfg.slow + 20:
|
||||||
|
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, self.cfg.atr_period)
|
||||||
|
cur_f, cur_s, cur_atr = fast[-1], slow[-1], atr_vals[-1]
|
||||||
|
if cur_f == 0 or cur_s == 0 or cur_atr == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 成交量确认:最近bar成交量 > 前20根均量 × factor
|
||||||
|
avg_vol = sum(self._volumes[-21:-1]) / 20 if n > 21 else 0
|
||||||
|
vol_confirmed = k.volume > avg_vol * self.cfg.vol_factor if avg_vol > 0 else True
|
||||||
|
|
||||||
|
if self._in_position:
|
||||||
|
self._bars_held += 1
|
||||||
|
self._highest_since_entry = max(self._highest_since_entry, k.high)
|
||||||
|
stop_price = self._highest_since_entry - self.cfg.atr_stop_mult * cur_atr
|
||||||
|
death_cross = fast[-2] >= slow[-2] and cur_f < cur_s
|
||||||
|
time_up = self._bars_held >= self.cfg.time_stop_bars
|
||||||
|
|
||||||
|
if k.close < stop_price:
|
||||||
|
self._in_position = False
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason=f"ATR止损", timestamp=k.open_time)
|
||||||
|
if death_cross:
|
||||||
|
self._in_position = False
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason=f"EMA死叉", timestamp=k.open_time)
|
||||||
|
if time_up:
|
||||||
|
self._in_position = False
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason=f"时间止损({self._bars_held}bar)", timestamp=k.open_time)
|
||||||
|
|
||||||
|
if not self._in_position:
|
||||||
|
golden = fast[-2] <= slow[-2] and cur_f > cur_s
|
||||||
|
if golden and vol_confirmed:
|
||||||
|
self._in_position = True
|
||||||
|
self._entry_price = k.close
|
||||||
|
self._highest_since_entry = k.close
|
||||||
|
self._bars_held = 0
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="BUY",
|
||||||
|
reason=f"金叉+放量{'✓' if vol_confirmed else ''} V={k.volume:.0f}",
|
||||||
|
timestamp=k.open_time)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# 参数扫描 + 对比
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
|
||||||
|
|
||||||
|
PARAM_GRID = [
|
||||||
|
# (fast, slow)
|
||||||
|
(10, 40), (10, 50), (10, 75),
|
||||||
|
(20, 40), (20, 50), (20, 75),
|
||||||
|
(30, 40), (30, 50), (30, 75),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 原始 v1 基线
|
||||||
|
BASELINE = {
|
||||||
|
"BTCUSDT": (20, 50, 45.5, 0.74, -31.6),
|
||||||
|
"ETHUSDT": (20, 50, 24.4, 0.47, -54.8),
|
||||||
|
"BNBUSDT": (20, 50, 52.0, 0.71, -39.8),
|
||||||
|
"SOLUSDT": (20, 50, 27.8, 0.49, -39.5),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def scan_one(symbol: str, fast: int, slow: int) -> dict:
|
||||||
|
"""单次参数扫描"""
|
||||||
|
bt = BacktestConfig(
|
||||||
|
symbol=symbol, interval="4h",
|
||||||
|
start_time=datetime(2024, 1, 1), end_time=datetime(2026, 1, 1),
|
||||||
|
initial_capital=10_000.0,
|
||||||
|
)
|
||||||
|
sc = EmaV3Config(symbol=symbol, fast=fast, slow=slow)
|
||||||
|
engine = BacktestEngine(bt, db_config=config.db)
|
||||||
|
r = await engine.run(EmaV3Strategy, sc)
|
||||||
|
m = r.metrics
|
||||||
|
return {"fast": fast, "slow": slow, "return": m.total_return_pct,
|
||||||
|
"sharpe": m.sharpe_ratio, "dd": m.max_drawdown_pct,
|
||||||
|
"trades": m.total_trades, "wr": m.win_rate}
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print()
|
||||||
|
print("═" * 105)
|
||||||
|
print(" EMA v3 深度优化 — 成交量+时间止损+参数扫描 | 4h 周期 | 2024-2026")
|
||||||
|
print("═" * 105)
|
||||||
|
|
||||||
|
all_results: dict[str, list[dict]] = {}
|
||||||
|
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
print(f"\n ▸ {symbol} 参数扫描 (9 组)...")
|
||||||
|
symbol_results = []
|
||||||
|
for fast, slow in PARAM_GRID:
|
||||||
|
r = await scan_one(symbol, fast, slow)
|
||||||
|
symbol_results.append(r)
|
||||||
|
print(f" EMA({fast},{slow}) 收益={r['return']:>+6.1f}% 夏普={r['sharpe']:>6.2f} 回撤={r['dd']:>6.1f}% 交易={r['trades']:>3} 胜率={r['wr']*100:>5.1f}%")
|
||||||
|
all_results[symbol] = symbol_results
|
||||||
|
|
||||||
|
# ── 每个币种最佳参数 ──
|
||||||
|
print("\n" + "═" * 105)
|
||||||
|
print(" ■ 各币种最优参数:")
|
||||||
|
print(f" {'币种':<10} {'最优参数':<16} {'收益%':>7} {'夏普':>6} {'回撤%':>7} {'交易':>5} {'胜率%':>6} vs 基线")
|
||||||
|
print("─" * 105)
|
||||||
|
|
||||||
|
best_params = {}
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
# 按夏普排序
|
||||||
|
sorted_r = sorted(all_results[symbol], key=lambda x: x["sharpe"], reverse=True)
|
||||||
|
best = sorted_r[0]
|
||||||
|
best_params[symbol] = (best["fast"], best["slow"])
|
||||||
|
base = BASELINE[symbol]
|
||||||
|
delta = best["return"] - base[2]
|
||||||
|
print(f" {symbol:<10} EMA({best['fast']},{best['slow']}){'':>8} {best['return']:>6.1f}% {best['sharpe']:>6.2f} {best['dd']:>6.1f}% {best['trades']:>5} {best['wr']*100:>5.1f}% Δ={delta:+.1f}%")
|
||||||
|
|
||||||
|
# ── 汇总对比 ──
|
||||||
|
print("\n" + "═" * 105)
|
||||||
|
print(" ■ 三层对比:基线(v1) → 止损(v2) → 成交量+时间止损+最优参数(v3)")
|
||||||
|
print(f" {'币种':<10} {'版本':<20} {'收益%':>7} {'夏普':>6} {'回撤%':>7} {'交易':>5} {'胜率%':>6}")
|
||||||
|
print("─" * 105)
|
||||||
|
|
||||||
|
# v2 数据(从上次运行)
|
||||||
|
V2 = {
|
||||||
|
"BTCUSDT": (20, 50, 7.0, 0.27, -27.8),
|
||||||
|
"ETHUSDT": (20, 50, 41.3, 0.76, -29.6),
|
||||||
|
"BNBUSDT": (20, 50, 43.6, 0.81, -26.1),
|
||||||
|
"SOLUSDT": (20, 50, 48.1, 0.70, -26.6),
|
||||||
|
}
|
||||||
|
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
# 基线
|
||||||
|
b = BASELINE[symbol]
|
||||||
|
print(f" {symbol:<10} {'v1 原始 EMA双均线':<20} {b[2]:>6.1f}% {b[3]:>6.2f} {b[4]:>6.1f}%")
|
||||||
|
# v2
|
||||||
|
v2 = V2[symbol]
|
||||||
|
print(f" {symbol:<10} {'v2 +ATR止损':<20} {v2[2]:>6.1f}% {v2[3]:>6.2f} {v2[4]:>6.1f}%")
|
||||||
|
# v3 最佳
|
||||||
|
best = [r for r in all_results[symbol] if r["fast"] == best_params[symbol][0] and r["slow"] == best_params[symbol][1]][0]
|
||||||
|
print(f" {symbol:<10} {'v3 全优化+最优参数':<20} {best['return']:>6.1f}% {best['sharpe']:>6.2f} {best['dd']:>6.1f}%")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── 组合收益 ──
|
||||||
|
print("─" * 105)
|
||||||
|
print(" ■ 等权组合(4币种各投入2500 USDT):")
|
||||||
|
total_baseline = sum(BASELINE[s][2] for s in SYMBOLS) / 4 * 100
|
||||||
|
total_v2 = sum(V2[s][2] for s in SYMBOLS) / 4 * 100
|
||||||
|
total_v3 = sum(
|
||||||
|
sorted(all_results[s], key=lambda x: x["sharpe"], reverse=True)[0]["return"]
|
||||||
|
for s in SYMBOLS
|
||||||
|
) / 4 * 100
|
||||||
|
print(f" v1 基线组合: {total_baseline:+.0f} USDT")
|
||||||
|
print(f" v2 止损组合: {total_v2:+.0f} USDT")
|
||||||
|
print(f" v3 全优化组合: {total_v3:+.0f} USDT")
|
||||||
|
|
||||||
|
print("\n═" * 105)
|
||||||
|
print(" 扫描完成。")
|
||||||
|
print("═" * 105)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"""
|
||||||
|
夏普比率专项优化 — 波动率自适应 + ADX过滤 + 分批止盈
|
||||||
|
|
||||||
|
优化手段(核心目标:提高收益/波动比):
|
||||||
|
1. 波动率自适应仓位 — ATR大→confidence小(少买),ATR小→confidence大(多买)
|
||||||
|
2. ADX 趋势过滤 — ADX>20 才交易,避开震荡市的反复假突破
|
||||||
|
3. 分批止盈 — RSI过热先出一半锁利,剩下一半ATR跟踪
|
||||||
|
|
||||||
|
对比:v1基线 / v3优化 / v4夏普优化
|
||||||
|
|
||||||
|
用法:
|
||||||
|
source .venv/bin/activate && python example/strategy_optimize3.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
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 ema, atr, rsi as calc_rsi, adx
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# EMA v4: 波动率自适应 + ADX过滤 + 分批止盈
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class EmaV4Config(StrategyConfig):
|
||||||
|
fast: int = 20
|
||||||
|
slow: int = 50
|
||||||
|
atr_period: int = 14
|
||||||
|
atr_stop_mult: float = 3.0
|
||||||
|
adx_period: int = 14
|
||||||
|
adx_threshold: float = 20.0 # ADX 趋势阈值
|
||||||
|
vol_base: float = 20.0 # ATR% 基准(周期数用于标准化)
|
||||||
|
rsi_period: int = 14
|
||||||
|
rsi_take_profit: float = 72.0 # 止盈 RSI 线
|
||||||
|
partial_exit_pct: float = 0.5 # 分批止盈比例(0=不分批)
|
||||||
|
|
||||||
|
|
||||||
|
class EmaV4Strategy(BaseStrategy):
|
||||||
|
"""EMA v4: 以夏普比率为目标的全方位优化"""
|
||||||
|
|
||||||
|
strategy_type = "ema_v4"
|
||||||
|
|
||||||
|
def __init__(self, c: EmaV4Config):
|
||||||
|
super().__init__(c)
|
||||||
|
self.cfg = c
|
||||||
|
self._closes: list[float] = []
|
||||||
|
self._highs: list[float] = []
|
||||||
|
self._lows: list[float] = []
|
||||||
|
self._highest_since_entry: float = 0.0
|
||||||
|
self._in_position = False
|
||||||
|
self._partial_done = 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)
|
||||||
|
n = len(self._closes)
|
||||||
|
need = max(self.cfg.slow, self.cfg.adx_period * 2)
|
||||||
|
if n < need + 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, self.cfg.atr_period)
|
||||||
|
adx_vals = adx(self._highs, self._lows, self._closes, self.cfg.adx_period)
|
||||||
|
rsi_vals = calc_rsi(self._closes, self.cfg.rsi_period)
|
||||||
|
|
||||||
|
cur_f, cur_s = fast[-1], slow[-1]
|
||||||
|
cur_atr, cur_adx, cur_rsi = atr_vals[-1], adx_vals[-1], rsi_vals[-1]
|
||||||
|
if cur_f == 0 or cur_s == 0 or cur_atr == 0 or cur_adx == 0 or cur_rsi == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── 波动率自适应仓位系数 ──
|
||||||
|
# ATR/价格 = 当前波动率,波动率越高→仓位越小
|
||||||
|
atr_pct = cur_atr / k.close if k.close > 0 else 0.02
|
||||||
|
# 基准波动率约 2%,波动率翻倍时仓位减半
|
||||||
|
vol_conf = min(1.0, max(0.2, 0.02 / max(atr_pct, 0.005)))
|
||||||
|
# ADX 趋势强度加成:强趋势更有信心
|
||||||
|
trend_bonus = min(1.3, max(0.7, cur_adx / 25))
|
||||||
|
position_conf = min(1.0, vol_conf * trend_bonus)
|
||||||
|
|
||||||
|
# ADX 趋势过滤
|
||||||
|
in_trend = cur_adx > self.cfg.adx_threshold
|
||||||
|
|
||||||
|
# ── 出场 ──
|
||||||
|
if self._in_position:
|
||||||
|
self._highest_since_entry = max(self._highest_since_entry, k.high)
|
||||||
|
stop_price = self._highest_since_entry - self.cfg.atr_stop_mult * cur_atr
|
||||||
|
death_cross = fast[-2] >= slow[-2] and cur_f < cur_s
|
||||||
|
|
||||||
|
# ATR 止损
|
||||||
|
if k.close < stop_price:
|
||||||
|
self._in_position = False
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL",
|
||||||
|
confidence=1.0,
|
||||||
|
reason=f"ATR止损 P={k.close:.0f}", timestamp=k.open_time)
|
||||||
|
|
||||||
|
# EMA 死叉
|
||||||
|
if death_cross:
|
||||||
|
self._in_position = False
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL",
|
||||||
|
confidence=1.0,
|
||||||
|
reason="EMA死叉", timestamp=k.open_time)
|
||||||
|
|
||||||
|
# 分批止盈:RSI过热先出一半
|
||||||
|
if not self._partial_done and cur_rsi > self.cfg.rsi_take_profit and self.cfg.partial_exit_pct > 0:
|
||||||
|
self._partial_done = True
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL",
|
||||||
|
quantity=None, # None=全部,但我们用confidence控制比例
|
||||||
|
confidence=self.cfg.partial_exit_pct,
|
||||||
|
reason=f"半仓止盈 RSI={cur_rsi:.0f}", timestamp=k.open_time)
|
||||||
|
|
||||||
|
# ── 入场 ──
|
||||||
|
if not self._in_position:
|
||||||
|
golden = fast[-2] <= slow[-2] and cur_f > cur_s
|
||||||
|
if golden and in_trend:
|
||||||
|
self._in_position = True
|
||||||
|
self._highest_since_entry = k.close
|
||||||
|
self._partial_done = False
|
||||||
|
return Signal(
|
||||||
|
symbol=self.cfg.symbol, side="BUY",
|
||||||
|
confidence=position_conf,
|
||||||
|
reason=f"金叉 ADX={cur_adx:.0f} vol_conf={vol_conf:.2f}",
|
||||||
|
timestamp=k.open_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
# 对比运行
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
|
||||||
|
|
||||||
|
# 各币种最优参数(来自 v3 扫描)
|
||||||
|
BEST_PARAMS = {
|
||||||
|
"BTCUSDT": (10, 50),
|
||||||
|
"ETHUSDT": (10, 75),
|
||||||
|
"BNBUSDT": (10, 40),
|
||||||
|
"SOLUSDT": (30, 50),
|
||||||
|
}
|
||||||
|
|
||||||
|
# v1 基线
|
||||||
|
V1 = {
|
||||||
|
"BTCUSDT": (45.5, 0.74, -31.6, 42, 26.2),
|
||||||
|
"ETHUSDT": (24.4, 0.47, -54.8, 41, 24.4),
|
||||||
|
"BNBUSDT": (52.0, 0.71, -39.8, 41, 39.0),
|
||||||
|
"SOLUSDT": (27.8, 0.49, -39.5, 45, 40.0),
|
||||||
|
}
|
||||||
|
|
||||||
|
# v3 最优参数结果
|
||||||
|
V3 = {
|
||||||
|
"BTCUSDT": (39.9, 1.03, -11.5, 20, 55.0),
|
||||||
|
"ETHUSDT": (53.6, 1.04, -15.3, 18, 38.9),
|
||||||
|
"BNBUSDT": (26.0, 0.64, -23.4, 23, 34.8),
|
||||||
|
"SOLUSDT": (73.6, 1.18, -25.7, 13, 46.2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_v4(symbol: str, fast: int, slow: int) -> BacktestResult:
|
||||||
|
bt = BacktestConfig(
|
||||||
|
symbol=symbol, interval="4h",
|
||||||
|
start_time=datetime(2024, 1, 1), end_time=datetime(2026, 1, 1),
|
||||||
|
initial_capital=10_000.0,
|
||||||
|
)
|
||||||
|
sc = EmaV4Config(symbol=symbol, fast=fast, slow=slow)
|
||||||
|
engine = BacktestEngine(bt, db_config=config.db)
|
||||||
|
return await engine.run(EmaV4Strategy, sc)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print()
|
||||||
|
print("═" * 120)
|
||||||
|
print(" 夏普比率专项优化 — 波动率自适应 + ADX过滤 + 分批止盈")
|
||||||
|
print("═" * 120)
|
||||||
|
|
||||||
|
# ── 扫描 partial_exit_pct 参数 ──
|
||||||
|
print("\n ▸ 分批止盈参数扫描 (SOLUSDT 为例)")
|
||||||
|
print(f" {'partial%':>10} {'收益%':>8} {'夏普':>6} {'回撤%':>8} {'交易':>5} {'胜率%':>6}")
|
||||||
|
print(" " + "─" * 50)
|
||||||
|
for pct in [0.0, 0.3, 0.5, 0.7]:
|
||||||
|
bt = BacktestConfig(symbol="SOLUSDT", interval="4h",
|
||||||
|
start_time=datetime(2024, 1, 1), end_time=datetime(2026, 1, 1),
|
||||||
|
initial_capital=10_000.0)
|
||||||
|
sc = EmaV4Config(symbol="SOLUSDT", fast=30, slow=50, partial_exit_pct=pct)
|
||||||
|
engine = BacktestEngine(bt, db_config=config.db)
|
||||||
|
r = await engine.run(EmaV4Strategy, sc)
|
||||||
|
m = r.metrics
|
||||||
|
print(f" {pct:>10.0%} {m.total_return_pct:>7.1f}% {m.sharpe_ratio:>6.2f} {m.max_drawdown_pct:>7.1f}% {m.total_trades:>5} {m.win_rate*100:>5.1f}%")
|
||||||
|
|
||||||
|
# ── 全部币种 v4 运行 ──
|
||||||
|
print("\n" + "═" * 120)
|
||||||
|
print(" ■ v1 → v3 → v4 夏普进化 | 使用各币种最优参数")
|
||||||
|
print(f" {'币种':<10} {'版本':<18} {'收益%':>7} {'夏普':>6} {'Δ夏普':>7} {'回撤%':>7} {'交易':>5} {'胜率%':>6}")
|
||||||
|
print("─" * 120)
|
||||||
|
|
||||||
|
v4_results = {}
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
fast, slow = BEST_PARAMS[symbol]
|
||||||
|
r = await run_v4(symbol, fast, slow)
|
||||||
|
v4_results[symbol] = r
|
||||||
|
|
||||||
|
v1 = V1[symbol]
|
||||||
|
v3 = V3[symbol]
|
||||||
|
m = r.metrics
|
||||||
|
|
||||||
|
# v1
|
||||||
|
print(f" {symbol:<10} {'v1 原始':<18} {v1[0]:>6.1f}% {v1[1]:>6.2f} {'—':>7} {v1[2]:>6.1f}% {v1[3]:>5} {v1[4]:>5.1f}%")
|
||||||
|
# v3
|
||||||
|
print(f" {symbol:<10} {'v3 最优参数':<18} {v3[0]:>6.1f}% {v3[1]:>6.2f} {v3[1]-v1[1]:>+6.2f} {v3[2]:>6.1f}%")
|
||||||
|
# v4
|
||||||
|
delta_sh = m.sharpe_ratio - v1[1]
|
||||||
|
print(f" {symbol:<10} {'v4 夏普优化':<18} {m.total_return_pct:>6.1f}% {m.sharpe_ratio:>6.2f} {delta_sh:>+6.2f} {m.max_drawdown_pct:>6.1f}% {m.total_trades:>5} {m.win_rate*100:>5.1f}%")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── 夏普改善汇总 ──
|
||||||
|
print("─" * 120)
|
||||||
|
print(" ■ 夏普比率改善汇总:")
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
v1_sh = V1[symbol][1]
|
||||||
|
v3_sh = V3[symbol][1]
|
||||||
|
v4_sh = v4_results[symbol].metrics.sharpe_ratio
|
||||||
|
print(f" {symbol}: v1={v1_sh:.2f} → v3={v3_sh:.2f} → v4={v4_sh:.2f} (总提升 {v4_sh-v1_sh:+.2f})")
|
||||||
|
|
||||||
|
# 平均夏普
|
||||||
|
avg_v1 = sum(V1[s][1] for s in SYMBOLS) / 4
|
||||||
|
avg_v3 = sum(V3[s][1] for s in SYMBOLS) / 4
|
||||||
|
avg_v4 = sum(v4_results[s].metrics.sharpe_ratio for s in SYMBOLS) / 4
|
||||||
|
print(f" 平均: v1={avg_v1:.2f} → v3={avg_v3:.2f} → v4={avg_v4:.2f} (总提升 {avg_v4-avg_v1:+.2f})")
|
||||||
|
|
||||||
|
print("\n═" * 120)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""
|
||||||
|
极简策略测试 — 一条均线 + 一个止损
|
||||||
|
|
||||||
|
核心理念:多加过滤条件往往不如简洁的信号。
|
||||||
|
|
||||||
|
策略:价格上穿 EMA(N) → 买入,价格下穿 EMA(N) → 卖出,ATR 动态止损。
|
||||||
|
无成交量、无多周期、无ADX、无双均线交叉。
|
||||||
|
|
||||||
|
对比 N=10/20/30,4个币种,4h周期,2024-2026。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
source .venv/bin/activate && python example/strategy_simple.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
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
|
||||||
|
from engine.indicators import ema, atr
|
||||||
|
|
||||||
|
|
||||||
|
class SingleEMAConfig(StrategyConfig):
|
||||||
|
period: int = 20
|
||||||
|
atr_stop: float = 2.5
|
||||||
|
|
||||||
|
|
||||||
|
class SingleEMAStrategy(BaseStrategy):
|
||||||
|
"""一条EMA均线 + ATR止损。没有更多了。"""
|
||||||
|
|
||||||
|
strategy_type = "single_ema"
|
||||||
|
|
||||||
|
def __init__(self, c: SingleEMAConfig):
|
||||||
|
super().__init__(c)
|
||||||
|
self.cfg = c
|
||||||
|
self._closes: list[float] = []
|
||||||
|
self._highs: list[float] = []
|
||||||
|
self._lows: list[float] = []
|
||||||
|
self._highest: float = 0.0
|
||||||
|
self._in_position = 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)
|
||||||
|
n = len(self._closes)
|
||||||
|
if n < self.cfg.period + 5:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ema_vals = ema(self._closes, self.cfg.period)
|
||||||
|
atr_vals = atr(self._highs, self._lows, self._closes, 14)
|
||||||
|
cur_ema, cur_atr = ema_vals[-1], atr_vals[-1]
|
||||||
|
prev_ema = ema_vals[-2]
|
||||||
|
if cur_ema == 0 or cur_atr == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── 出场 ──
|
||||||
|
if self._in_position:
|
||||||
|
self._highest = max(self._highest, k.high)
|
||||||
|
stop = self._highest - self.cfg.atr_stop * cur_atr
|
||||||
|
cross_down = self._closes[-2] >= prev_ema and k.close < cur_ema
|
||||||
|
|
||||||
|
if k.close < stop:
|
||||||
|
self._in_position = False
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason="ATR止损", timestamp=k.open_time)
|
||||||
|
if cross_down:
|
||||||
|
self._in_position = False
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL",
|
||||||
|
reason=f"下穿EMA{self.cfg.period}", timestamp=k.open_time)
|
||||||
|
|
||||||
|
# ── 入场 ──
|
||||||
|
if not self._in_position:
|
||||||
|
cross_up = self._closes[-2] <= prev_ema and k.close > cur_ema
|
||||||
|
if cross_up:
|
||||||
|
self._in_position = True
|
||||||
|
self._highest = k.close
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="BUY",
|
||||||
|
reason=f"上穿EMA{self.cfg.period}", timestamp=k.open_time)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
|
||||||
|
PERIODS = [10, 20, 30]
|
||||||
|
DATE_START = datetime(2024, 1, 1)
|
||||||
|
DATE_END = datetime(2026, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print()
|
||||||
|
print("═" * 105)
|
||||||
|
print(" 极简策略 — 一条 EMA + ATR 止损 | 4h | 2024-2026")
|
||||||
|
print("═" * 105)
|
||||||
|
print(f" {'EMA':<8} {'币种':<10} {'收益%':>7} {'夏普':>6} {'回撤%':>7} {'交易':>5} {'胜率%':>6} {'盈亏比':>6}")
|
||||||
|
print("─" * 105)
|
||||||
|
|
||||||
|
all_rows = []
|
||||||
|
for period in PERIODS:
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
sc = SingleEMAConfig(symbol=symbol, period=period)
|
||||||
|
bt = BacktestConfig(symbol=symbol, interval="4h",
|
||||||
|
start_time=DATE_START, end_time=DATE_END, initial_capital=10_000.0)
|
||||||
|
engine = BacktestEngine(bt, db_config=config.db)
|
||||||
|
r = await engine.run(SingleEMAStrategy, sc)
|
||||||
|
m = r.metrics
|
||||||
|
all_rows.append((period, symbol, m))
|
||||||
|
print(f" EMA({period:<2}) {symbol:<10} {m.total_return_pct:>6.1f}% {m.sharpe_ratio:>6.2f} {m.max_drawdown_pct:>6.1f}% {m.total_trades:>5} {m.win_rate*100:>5.1f}% {m.profit_factor:>6.2f}")
|
||||||
|
|
||||||
|
# ── 对比之前最优结果 ──
|
||||||
|
print("─" * 105)
|
||||||
|
print("\n ■ 对比:极简 vs 之前最优 (EMA v3 双均线)")
|
||||||
|
V3 = {
|
||||||
|
"BTCUSDT": (39.9, 1.03, -11.5),
|
||||||
|
"ETHUSDT": (53.6, 1.04, -15.3),
|
||||||
|
"BNBUSDT": (26.0, 0.64, -23.4),
|
||||||
|
"SOLUSDT": (73.6, 1.18, -25.7),
|
||||||
|
}
|
||||||
|
print(f" {'币种':<10} {'策略':<20} {'收益%':>7} {'夏普':>6} {'回撤%':>7}")
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
v3 = V3[symbol]
|
||||||
|
print(f" {symbol:<10} {'EMA v3(最优参数)':<20} {v3[0]:>6.1f}% {v3[1]:>6.2f} {v3[2]:>6.1f}%")
|
||||||
|
# 找极简最佳
|
||||||
|
best = max([(p, m) for p, s, m in all_rows if s == symbol], key=lambda x: x[1].sharpe_ratio)
|
||||||
|
print(f" {'':<10} {'单EMA('+str(best[0])+') 极简':<20} {best[1].total_return_pct:>6.1f}% {best[1].sharpe_ratio:>6.2f} {best[1].max_drawdown_pct:>6.1f}%")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# ── 汇总 ──
|
||||||
|
print("─" * 105)
|
||||||
|
ranked = sorted(all_rows, key=lambda x: x[2].sharpe_ratio, reverse=True)
|
||||||
|
print(" ■ 按夏普 TOP 5:")
|
||||||
|
for i, (p, s, m) in enumerate(ranked[:5]):
|
||||||
|
print(f" {i+1}. {s} EMA({p:<2}) 夏普={m.sharpe_ratio:.2f} 收益={m.total_return_pct:+.1f}% 回撤={m.max_drawdown_pct:.1f}% 胜率={m.win_rate*100:.0f}% 交易={m.total_trades}")
|
||||||
|
|
||||||
|
avg_sh = sum(x[2].sharpe_ratio for x in all_rows) / len(all_rows)
|
||||||
|
print(f"\n 12组平均夏普: {avg_sh:.2f}")
|
||||||
|
|
||||||
|
print("\n═" * 105)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""
|
||||||
|
同周期三EMA策略 — 4h EMA50>200 定方向 + EMA20金叉EMA50入场
|
||||||
|
|
||||||
|
所有信号在同一周期(4h)上,不跨级。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
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.base import BaseStrategy, Signal, StrategyConfig
|
||||||
|
from engine.common.models import Kline
|
||||||
|
from engine.common.config import config
|
||||||
|
from engine.backtest import BacktestEngine, BacktestConfig
|
||||||
|
from engine.indicators import ema, atr
|
||||||
|
|
||||||
|
|
||||||
|
class ThreeEMAConfig(StrategyConfig):
|
||||||
|
ema_entry: int = 20 # 入场均线(金叉慢线时入场)
|
||||||
|
ema_trend: int = 50 # 趋势均线(在200上方=多头)
|
||||||
|
ema_filter: int = 200 # 长期过滤(50必须在其上)
|
||||||
|
atr_stop: float = 2.5
|
||||||
|
|
||||||
|
|
||||||
|
class ThreeEMAStrategy(BaseStrategy):
|
||||||
|
"""三EMA同周期策略
|
||||||
|
|
||||||
|
EMA200 长期方向 → EMA50>200 才做多
|
||||||
|
EMA20 金叉 EMA50 → 入场
|
||||||
|
EMA20 死叉 EMA50 或 EMA50<200 → 出场
|
||||||
|
ATR 动态止损
|
||||||
|
"""
|
||||||
|
|
||||||
|
strategy_type = "three_ema"
|
||||||
|
|
||||||
|
def __init__(self, c: ThreeEMAConfig):
|
||||||
|
super().__init__(c)
|
||||||
|
self.cfg = c
|
||||||
|
self._closes: list[float] = []
|
||||||
|
self._highs: list[float] = []
|
||||||
|
self._lows: list[float] = []
|
||||||
|
self._highest: float = 0.0
|
||||||
|
self._in_position = 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)
|
||||||
|
n = len(self._closes)
|
||||||
|
if n < self.cfg.ema_filter + 10:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 三条EMA
|
||||||
|
e20 = ema(self._closes, self.cfg.ema_entry)
|
||||||
|
e50 = ema(self._closes, self.cfg.ema_trend)
|
||||||
|
e200 = ema(self._closes, self.cfg.ema_filter)
|
||||||
|
atr_vals = atr(self._highs, self._lows, self._closes, 14)
|
||||||
|
|
||||||
|
# 当前值和前值
|
||||||
|
c20, p20 = e20[-1], e20[-2]
|
||||||
|
c50, p50 = e50[-1], e50[-2]
|
||||||
|
c200 = e200[-1]
|
||||||
|
cur_atr = atr_vals[-1]
|
||||||
|
|
||||||
|
if c20 == 0 or c50 == 0 or c200 == 0 or cur_atr == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
is_bull = c50 > c200 # EMA50在200上方=多头市场
|
||||||
|
golden = p20 <= p50 and c20 > p50 # 金叉
|
||||||
|
death = p20 >= p50 and c20 < p50 # 死叉
|
||||||
|
|
||||||
|
# ── 出场 ──
|
||||||
|
if self._in_position:
|
||||||
|
self._highest = max(self._highest, k.high)
|
||||||
|
stop = self._highest - self.cfg.atr_stop * cur_atr
|
||||||
|
|
||||||
|
if not is_bull:
|
||||||
|
self._in_position = False
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason="EMA50<200转空", timestamp=k.open_time)
|
||||||
|
if k.close < stop:
|
||||||
|
self._in_position = False
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason="ATR止损", timestamp=k.open_time)
|
||||||
|
if death:
|
||||||
|
self._in_position = False
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="SELL", reason="EMA20死叉50", timestamp=k.open_time)
|
||||||
|
|
||||||
|
# ── 入场 ──
|
||||||
|
if not self._in_position and is_bull and golden:
|
||||||
|
self._in_position = True
|
||||||
|
self._highest = k.close
|
||||||
|
return Signal(symbol=self.cfg.symbol, side="BUY",
|
||||||
|
reason=f"EMA20金叉50 多头确认", timestamp=k.open_time)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
|
||||||
|
SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
|
||||||
|
DATE_START = datetime(2024, 1, 1)
|
||||||
|
DATE_END = datetime(2026, 1, 1)
|
||||||
|
|
||||||
|
# 之前最优对比
|
||||||
|
BEST = {
|
||||||
|
"BTCUSDT": ("EMA v3(10,50)", 39.9, 1.03, -11.5, 20),
|
||||||
|
"ETHUSDT": ("EMA v3(10,75)", 53.6, 1.04, -15.3, 18),
|
||||||
|
"BNBUSDT": ("EMA v1(20,50)", 52.0, 0.71, -39.8, 41),
|
||||||
|
"SOLUSDT": ("EMA v3(30,50)", 73.6, 1.18, -25.7, 13),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
print()
|
||||||
|
print("═" * 105)
|
||||||
|
print(" 三EMA同周期 — 4h EMA200定势 / EMA20×50交易 | 2024-2026")
|
||||||
|
print("═" * 105)
|
||||||
|
print(f" {'币种':<10} {'收益%':>7} {'夏普':>6} {'回撤%':>7} {'交易':>5} {'胜率%':>6} {'盈亏比':>6} {'vs最优':>8}")
|
||||||
|
print("─" * 105)
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
sc = ThreeEMAConfig(symbol=symbol)
|
||||||
|
bt = BacktestConfig(symbol=symbol, interval="4h",
|
||||||
|
start_time=DATE_START, end_time=DATE_END, initial_capital=10_000.0)
|
||||||
|
engine = BacktestEngine(bt, db_config=config.db)
|
||||||
|
r = await engine.run(ThreeEMAStrategy, sc)
|
||||||
|
m = r.metrics
|
||||||
|
results[symbol] = m
|
||||||
|
|
||||||
|
_, best_ret, best_sh, _, _ = BEST[symbol]
|
||||||
|
delta = m.total_return_pct - best_ret
|
||||||
|
tag = " ← 新最佳!" if m.sharpe_ratio > best_sh else ""
|
||||||
|
|
||||||
|
print(f" {symbol:<10} {m.total_return_pct:>6.1f}% {m.sharpe_ratio:>6.2f} {m.max_drawdown_pct:>6.1f}% {m.total_trades:>5} {m.win_rate*100:>5.1f}% {m.profit_factor:>6.2f} {delta:>+7.1f}%{tag}")
|
||||||
|
|
||||||
|
sells = [t for t in r.trades if t.side == "SELL" and t.pnl is not None]
|
||||||
|
for t in sells[-2:]:
|
||||||
|
dt = datetime.fromtimestamp(t.timestamp / 1000, tz=timezone.utc).strftime("%m-%d %H:%M")
|
||||||
|
print(f" {'':<10} └ {dt} {t.pnl:>+8.2f} {t.reason}")
|
||||||
|
|
||||||
|
print("─" * 105)
|
||||||
|
print(f"\n {'币种':<10} {'之前最优':<20} {'收益%':>7} {'夏普':>6} → {'三EMA收益%':>9} {'三EMA夏普':>8}")
|
||||||
|
for symbol in SYMBOLS:
|
||||||
|
name, ret, sh, _, _ = BEST[symbol]
|
||||||
|
m = results[symbol]
|
||||||
|
print(f" {symbol:<10} {name:<20} {ret:>6.1f}% {sh:>6.2f} → {m.total_return_pct:>8.1f}% {m.sharpe_ratio:>7.2f}")
|
||||||
|
|
||||||
|
print("\n═" * 105)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
Reference in New Issue
Block a user