diff --git a/engine/example/backtest_demo.py b/engine/example/backtest_demo.py new file mode 100644 index 0000000..cf6cdbb --- /dev/null +++ b/engine/example/backtest_demo.py @@ -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()) diff --git a/engine/example/cross_section.py b/engine/example/cross_section.py new file mode 100644 index 0000000..d1ecd1f --- /dev/null +++ b/engine/example/cross_section.py @@ -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()) diff --git a/engine/example/data.py b/engine/example/data.py new file mode 100644 index 0000000..c457482 --- /dev/null +++ b/engine/example/data.py @@ -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()) diff --git a/engine/example/factor_demo.py b/engine/example/factor_demo.py new file mode 100644 index 0000000..d70d790 --- /dev/null +++ b/engine/example/factor_demo.py @@ -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 (动量) │ 3075 过热出场 │ + │ 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()) diff --git a/engine/example/full_cycle.py b/engine/example/full_cycle.py new file mode 100644 index 0000000..06a6691 --- /dev/null +++ b/engine/example/full_cycle.py @@ -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()) diff --git a/engine/example/long_short.py b/engine/example/long_short.py new file mode 100644 index 0000000..4eeae9e --- /dev/null +++ b/engine/example/long_short.py @@ -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()) diff --git a/engine/example/multi_tf_demo.py b/engine/example/multi_tf_demo.py new file mode 100644 index 0000000..773f1ad --- /dev/null +++ b/engine/example/multi_tf_demo.py @@ -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} 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()) diff --git a/engine/example/multi_tf_demo2.py b/engine/example/multi_tf_demo2.py new file mode 100644 index 0000000..d393a17 --- /dev/null +++ b/engine/example/multi_tf_demo2.py @@ -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} 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()) diff --git a/engine/example/multi_tf_ema200.py b/engine/example/multi_tf_ema200.py new file mode 100644 index 0000000..bcf44c6 --- /dev/null +++ b/engine/example/multi_tf_ema200.py @@ -0,0 +1,193 @@ +""" +4h EMA50>EMA200 定大势 / 30m 找买点 + +策略: + 4h EMA50 > EMA200 → 中长期多头趋势确认,允许做多 + 30m 入场 → 4h多头区间中,30m价格上穿EMA20 + 收阳 → 买入 + 30m 出场 → 下穿EMA20 或 4h趋势打破(EMA50200 多头趋势 + 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()) diff --git a/engine/example/multi_tf_v3.py b/engine/example/multi_tf_v3.py new file mode 100644 index 0000000..681bdec --- /dev/null +++ b/engine/example/multi_tf_v3.py @@ -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()) diff --git a/engine/example/regime_all.py b/engine/example/regime_all.py new file mode 100644 index 0000000..f38bc38 --- /dev/null +++ b/engine/example/regime_all.py @@ -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()) diff --git a/engine/example/regime_detect.py b/engine/example/regime_detect.py new file mode 100644 index 0000000..80771ae --- /dev/null +++ b/engine/example/regime_detect.py @@ -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()) diff --git a/engine/example/regime_detect2.py b/engine/example/regime_detect2.py new file mode 100644 index 0000000..9e1d3c1 --- /dev/null +++ b/engine/example/regime_detect2.py @@ -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()) diff --git a/engine/example/regime_timeframe_comparison.py b/engine/example/regime_timeframe_comparison.py new file mode 100644 index 0000000..aa3b4a3 --- /dev/null +++ b/engine/example/regime_timeframe_comparison.py @@ -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()) diff --git a/engine/example/strategy_battle.py b/engine/example/strategy_battle.py new file mode 100644 index 0000000..bc11746 --- /dev/null +++ b/engine/example/strategy_battle.py @@ -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} 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()) diff --git a/engine/example/strategy_more.py b/engine/example/strategy_more.py new file mode 100644 index 0000000..4d92f5f --- /dev/null +++ b/engine/example/strategy_more.py @@ -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} 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()) diff --git a/engine/example/strategy_optimize.py b/engine/example/strategy_optimize.py new file mode 100644 index 0000000..dc3ac9b --- /dev/null +++ b/engine/example/strategy_optimize.py @@ -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()) diff --git a/engine/example/strategy_optimize2.py b/engine/example/strategy_optimize2.py new file mode 100644 index 0000000..d07dea2 --- /dev/null +++ b/engine/example/strategy_optimize2.py @@ -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()) diff --git a/engine/example/strategy_optimize3.py b/engine/example/strategy_optimize3.py new file mode 100644 index 0000000..e7a465f --- /dev/null +++ b/engine/example/strategy_optimize3.py @@ -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()) diff --git a/engine/example/strategy_simple.py b/engine/example/strategy_simple.py new file mode 100644 index 0000000..2e35b4f --- /dev/null +++ b/engine/example/strategy_simple.py @@ -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()) diff --git a/engine/example/three_ema.py b/engine/example/three_ema.py new file mode 100644 index 0000000..931b149 --- /dev/null +++ b/engine/example/three_ema.py @@ -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())