feat: 新增2h/6h时间框架支持,策略重构为增量指标计算

- 数据层: build_aggregates_sql 新增 2h/6h 聚合视图,默认起始时间调整为 2017-05
- 模型层: KlineInterval 类型扩展 2h/6h,DataService 新增对应表名和毫秒映射
- 指标层: 新增 incremental.py 增量指标模块 (EmaInc/AtrInc/RsiInc/BbInc),O(1) per bar
- 策略重构: long_short.py 和 regime_all.py 从批量 ema/atr 迁移至增量指标,避免每 bar 重复全量计算
- regime 探测器: RegimeDetector3 改为增量 EMA200,detect() 接口简化
- 回测扩展: regime_timeframe_comparison 从 4h/1d 扩展至 2h/4h/6h/1d
- 新增示例: multi_strategy_report, vol_break_compare/periods, intraday_explore, top3_trades 等分析脚本
This commit is contained in:
Rekey
2026-06-13 19:30:25 +08:00
parent b5cdb41993
commit edc50e8809
20 changed files with 484544 additions and 34 deletions
+563
View File
@@ -0,0 +1,563 @@
"""
日内策略探索 — 4 种思路 (15m / 30m / 1h 全币种)
1. 均值回归:RSI超买超卖 + 布林带触碰,震荡市中做回归
2. 多时间框架:4h 牛熊判定方向过滤 + 1h EMA交叉入场
3. 波动率突破:ATR 收缩后扩张,顺势突破
4. 成交量:OBV 背离 + VWAP 回归
用法:
source .venv/bin/activate && python example/intraday_explore.py
"""
import asyncio
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
_project_root = Path(__file__).resolve().parent.parent.parent
if str(_project_root) not in sys.path:
sys.path.insert(0, str(_project_root))
from engine.common.base import BaseStrategy, Signal, StrategyConfig
from engine.common.models import Kline
from engine.common.config import config
from engine.backtest import BacktestConfig
from engine.data import DataService
from engine.indicators.incremental import EmaInc, AtrInc, RsiInc, BbInc
from engine.example.long_short import LongShortEngine
from engine.example.regime_all import RegimeDetector3, RegimeEmaConfig, RegimeEmaStrategy
# ════════════════════════════════════════════════════════
# 策略 1:均值回归 — RSI + 布林带
# ════════════════════════════════════════════════════════
class MeanRevConfig(StrategyConfig):
rsi_period: int = 14
rsi_oversold: float = 25.0
rsi_overbought: float = 75.0
bb_period: int = 20
bb_std: float = 2.0
atr_stop: float = 1.5
require_bb_touch: bool = True # 是否要求价格触碰布林带
class MeanRevStrategy(BaseStrategy):
"""RSI 极端 + 布林带确认 → 均值回归,ATR 止损"""
strategy_type = "mean_rev"
def __init__(self, c: MeanRevConfig):
super().__init__(c)
self.cfg = c
self._rsi = RsiInc(c.rsi_period)
self._bb = BbInc(c.bb_period, c.bb_std)
self._atr = AtrInc(14)
self._side: str = "" # "long" / "short"
self._entry_price: float = 0.0
async def on_kline(self, k: Kline) -> Optional[Signal]:
r = self._rsi.update(k.close)
up, mid, lo = self._bb.update(k.close)
atr_v = self._atr.update(k.high, k.low, k.close)
if r == 0 or up == 0 or atr_v == 0:
return None
below_bb = k.close < lo if self.cfg.require_bb_touch else True
above_bb = k.close > up if self.cfg.require_bb_touch else True
# ── 持仓管理 ──
if self._side == "long":
stop = self._entry_price - self.cfg.atr_stop * atr_v
take = self._entry_price + self.cfg.atr_stop * atr_v * 1.5
if k.close <= stop or k.close >= take or r > 55: # 回归中轨或超止损
self._side = ""
reason = "止损" if k.close <= stop else ("止盈" if k.close >= take else "RSI回归中轨")
return Signal(symbol=self.cfg.symbol, side="SELL", reason=reason, timestamp=k.open_time)
elif self._side == "short":
stop = self._entry_price + self.cfg.atr_stop * atr_v
take = self._entry_price - self.cfg.atr_stop * atr_v * 1.5
if k.close >= stop or k.close <= take or r < 45:
self._side = ""
reason = "止损" if k.close >= stop else ("止盈" if k.close <= take else "RSI回归中轨")
return Signal(symbol=self.cfg.symbol, side="BUY", reason=reason, timestamp=k.open_time)
# ── 入场 ──
else:
if r < self.cfg.rsi_oversold and below_bb:
self._side = "long"
self._entry_price = k.close
return Signal(symbol=self.cfg.symbol, side="BUY", reason=f"RSI超卖{r:.0f}", timestamp=k.open_time)
elif r > self.cfg.rsi_overbought and above_bb:
self._side = "short"
self._entry_price = k.close
return Signal(symbol=self.cfg.symbol, side="SELL", reason=f"RSI超买{r:.0f}", timestamp=k.open_time)
return None
# ════════════════════════════════════════════════════════
# 策略 2:多时间框架 — 4h 方向 + 1h 入场
# ════════════════════════════════════════════════════════
class MultiTFConfig(StrategyConfig):
fast: int = 20
slow: int = 100
atr_stop: float = 2.0
# 4h 数据由策略内部自动加载
class MultiTFStrategy(BaseStrategy):
"""4h 牛熊判定方向过滤,1h EMA 交叉入场,只顺大势"""
strategy_type = "multi_tf"
def __init__(self, c: MultiTFConfig):
super().__init__(c)
self.cfg = c
self._ema_fast = EmaInc(c.fast)
self._ema_slow = EmaInc(c.slow)
self._atr = AtrInc(14)
self._side: str = ""
self._hp: float = 0.0
self._lp: float = float("inf")
# 4h 牛熊判定 — 在 on_start 中加载
self._regime_map: dict[int, str] = {} # timestamp_hour -> regime
self._4h_loaded = False
async def on_start(self) -> None:
"""加载 4h 数据并预计算牛熊判定"""
await super().on_start()
if self._4h_loaded:
return
try:
ds = DataService(config.db)
await ds.connect()
try:
klines_4h = await ds.fetch_klines(
symbol=self.cfg.symbol, interval="4h",
start_time=datetime(2017, 1, 1),
end_time=datetime(2026, 12, 31),
limit=1_000_000,
)
detector = RegimeDetector3()
for k in klines_4h:
detector.update(k.close)
idx = len(detector._e200) - 1
if idx >= 220:
regime = detector.detect(k.close, idx)
# 4h bar 覆盖的时间窗口(按小时取整)
hour_key = int(k.open_time / 3_600_000)
for h in range(4):
self._regime_map[hour_key + h] = regime
self._4h_loaded = True
finally:
await ds.close()
except Exception:
pass # 加载失败则不做过滤
def _get_regime(self, ts_ms: float) -> str:
"""根据 1h bar 时间戳查找对应 4h 牛熊状态"""
hour_key = int(ts_ms / 3_600_000)
return self._regime_map.get(hour_key, "sideways")
async def on_kline(self, k: Kline) -> Optional[Signal]:
self._ema_fast.update(k.close)
self._ema_slow.update(k.close)
self._atr.update(k.high, k.low, k.close)
n = len(self._ema_fast)
if n < self.cfg.slow + 5:
return None
cf, cs = self._ema_fast[-1], self._ema_slow[-1]
pf, ps = self._ema_fast[-2], self._ema_slow[-2]
ca = self._atr[-1]
if cf == 0 or cs == 0 or ca == 0:
return None
golden = pf <= ps and cf > cs
death = pf >= ps and cf < cs
regime = self._get_regime(k.open_time)
# ── 多头持仓 ──
if self._side == "long":
self._hp = max(self._hp, k.high)
stop = self._hp - self.cfg.atr_stop * ca
if death or k.close < stop:
self._side = ""
reason = "死叉" if death else "ATR止损"
return Signal(symbol=self.cfg.symbol, side="SELL", reason=reason, timestamp=k.open_time)
# ── 空头持仓 ──
elif self._side == "short":
self._lp = min(self._lp, k.low)
stop = self._lp + self.cfg.atr_stop * ca
if golden or k.close > stop:
self._side = ""
reason = "金叉" if golden else "ATR止损"
return Signal(symbol=self.cfg.symbol, side="BUY", reason=reason, timestamp=k.open_time)
# ── 入场:必须顺4h方向 ──
else:
if golden and regime == "bull":
self._side = "long"
self._hp = k.close
return Signal(symbol=self.cfg.symbol, side="BUY", reason="4h牛+金叉", timestamp=k.open_time)
elif death and regime == "bear":
self._side = "short"
self._lp = k.close
return Signal(symbol=self.cfg.symbol, side="SELL", reason="4h熊+死叉", timestamp=k.open_time)
return None
# ════════════════════════════════════════════════════════
# 策略 3:波动率突破 — ATR 收缩扩张
# ════════════════════════════════════════════════════════
class VolBreakConfig(StrategyConfig):
atr_period: int = 14
squeeze_period: int = 20 # ATR 收缩回看窗口
squeeze_ratio: float = 0.7 # 当前 ATR < 最低 ATR * ratio 时视为收缩
atr_stop: float = 2.0
class VolBreakStrategy(BaseStrategy):
"""ATR 收缩到极致后扩张 → 顺势突破,ATR 止损"""
strategy_type = "vol_break"
def __init__(self, c: VolBreakConfig):
super().__init__(c)
self.cfg = c
self._atr = AtrInc(c.atr_period)
self._ema_fast = EmaInc(10)
self._ema_slow = EmaInc(30)
self._closes: list[float] = []
self._highs: list[float] = []
self._lows: list[float] = []
self._side: str = ""
self._entry_price: float = 0.0
self._was_squeezed = False
async def on_kline(self, k: Kline) -> Optional[Signal]:
self._closes.append(k.close)
self._highs.append(k.high)
self._lows.append(k.low)
self._atr.update(k.high, k.low, k.close)
self._ema_fast.update(k.close)
self._ema_slow.update(k.close)
n = len(self._closes)
if n < self.cfg.atr_period + self.cfg.squeeze_period:
return None
atr_now = self._atr[-1]
atr_prev = self._atr[-2] if n >= 2 else 0
ca = atr_now
if ca == 0:
return None
# ATR 收缩检测:当前 ATR 是否处于 squeeze_period 内的最低水平
atr_window = [self._atr[i] for i in range(max(0, n - self.cfg.squeeze_period), n) if self._atr[i] > 0]
if not atr_window:
return None
min_atr = min(atr_window)
is_squeezed = atr_now < min_atr * (1 + (1 - self.cfg.squeeze_ratio))
# ATR 扩张信号
atr_expanding = atr_now > atr_prev * 1.05 if atr_prev > 0 else False
# 趋势方向
cf = self._ema_fast[-1]
cs = self._ema_slow[-1]
trend_up = cf > cs
# ── 持仓管理 ──
if self._side == "long":
self._was_squeezed = False
stop = self._entry_price - self.cfg.atr_stop * ca
if k.close < stop or (cf < cs and not is_squeezed):
self._side = ""
return Signal(symbol=self.cfg.symbol, side="SELL", reason="ATR止损或转弱", timestamp=k.open_time)
elif self._side == "short":
self._was_squeezed = False
stop = self._entry_price + self.cfg.atr_stop * ca
if k.close > stop or (cf > cs and not is_squeezed):
self._side = ""
return Signal(symbol=self.cfg.symbol, side="BUY", reason="ATR止损或转强", timestamp=k.open_time)
# ── 入场 ──
else:
if is_squeezed:
self._was_squeezed = True
elif self._was_squeezed and atr_expanding:
self._was_squeezed = False
if trend_up:
self._side = "long"
self._entry_price = k.close
return Signal(symbol=self.cfg.symbol, side="BUY", reason="ATR扩张突破做多", timestamp=k.open_time)
else:
self._side = "short"
self._entry_price = k.close
return Signal(symbol=self.cfg.symbol, side="SELL", reason="ATR扩张突破做空", timestamp=k.open_time)
return None
# ════════════════════════════════════════════════════════
# 策略 4:成交量 — OBV 背离 + VWAP 回归
# ════════════════════════════════════════════════════════
class VolumeConfig(StrategyConfig):
obv_lookback: int = 20 # OBV 背离检测窗口
vwap_std: float = 2.0 # VWAP 偏离标准差倍数
atr_stop: float = 2.0
class VolumeStrategy(BaseStrategy):
"""OBV 背离(价格新低但 OBV 未新低→看涨)+ VWAP 偏离回归"""
strategy_type = "volume"
def __init__(self, c: VolumeConfig):
super().__init__(c)
self.cfg = c
self._closes: list[float] = []
self._highs: list[float] = []
self._lows: list[float] = []
self._volumes: list[float] = []
self._obv: list[float] = [] # 增量 OBV
self._obv_val: float = 0.0
self._atr = AtrInc(14)
self._cum_pv: float = 0.0 # 累积 price*volume
self._cum_vol: float = 0.0 # 累积 volume
self._side: str = ""
self._entry_price: float = 0.0
async def on_kline(self, k: Kline) -> Optional[Signal]:
self._closes.append(k.close)
self._highs.append(k.high)
self._lows.append(k.low)
self._volumes.append(k.volume)
self._atr.update(k.high, k.low, k.close)
# 增量 OBV
n = len(self._closes)
if n == 1:
self._obv_val = k.volume
else:
if k.close > self._closes[-2]:
self._obv_val += k.volume
elif k.close < self._closes[-2]:
self._obv_val -= k.volume
self._obv.append(self._obv_val)
# 增量 VWAP
typical = (k.high + k.low + k.close) / 3.0
self._cum_pv += typical * k.volume
self._cum_vol += k.volume
vwap = self._cum_pv / self._cum_vol if self._cum_vol > 0 else k.close
if n < self.cfg.obv_lookback + 5:
return None
ca = self._atr[-1]
if ca == 0:
return None
# OBV 背离检测:价格新低但 OBV 未新低 → 潜在反转
lookback = self.cfg.obv_lookback
price_window = self._closes[-lookback:]
obv_window = self._obv[-lookback:]
price_made_new_low = min(price_window) == price_window[-1]
obv_not_new_low = min(obv_window) < obv_window[-1]
obv_bull_div = price_made_new_low and obv_not_new_low
# OBV 负背离:价格新高但 OBV 未新高
price_made_new_high = max(price_window) == price_window[-1]
obv_not_new_high = max(obv_window) > obv_window[-1]
obv_bear_div = price_made_new_high and obv_not_new_high
# VWAP 偏离度
vwap_dev = (k.close - vwap) / vwap if vwap > 0 else 0
# ── 持仓管理 ──
if self._side == "long":
stop = self._entry_price - self.cfg.atr_stop * ca
if k.close < stop or vwap_dev < -0.01: # 回到 VWAP 下方
self._side = ""
return Signal(symbol=self.cfg.symbol, side="SELL", reason="止损或回VWAP", timestamp=k.open_time)
elif self._side == "short":
stop = self._entry_price + self.cfg.atr_stop * ca
if k.close > stop or vwap_dev > 0.01:
self._side = ""
return Signal(symbol=self.cfg.symbol, side="BUY", reason="止损或回VWAP", timestamp=k.open_time)
# ── 入场 ──
else:
if obv_bull_div and vwap_dev < -self.cfg.vwap_std * 0.02: # 价格显著低于 VWAP
self._side = "long"
self._entry_price = k.close
return Signal(symbol=self.cfg.symbol, side="BUY", reason=f"OBV底背离+VWAP下方", timestamp=k.open_time)
elif obv_bear_div and vwap_dev > self.cfg.vwap_std * 0.02:
self._side = "short"
self._entry_price = k.close
return Signal(symbol=self.cfg.symbol, side="SELL", reason=f"OBV顶背离+VWAP上方", timestamp=k.open_time)
return None
# ════════════════════════════════════════════════════════
# 执行
# ════════════════════════════════════════════════════════
SYMBOLS = ["BTCUSDT", "ETHUSDT", "BNBUSDT", "SOLUSDT"]
INTERVALS = ["15m", "30m", "1h"]
STRATEGIES = {
"1.均值回归": (MeanRevConfig, MeanRevStrategy),
"2.多TF(4h+1h)": (MultiTFConfig, MultiTFStrategy),
"3.波动突破": (VolBreakConfig, VolBreakStrategy),
"4.成交量": (VolumeConfig, VolumeStrategy),
}
# 均值回归参数
MEAN_REV_PARAMS = {
"BTCUSDT": {"rsi_period": 14, "rsi_oversold": 25, "rsi_overbought": 75, "bb_period": 20, "bb_std": 2.0, "atr_stop": 1.5},
"ETHUSDT": {"rsi_period": 14, "rsi_oversold": 25, "rsi_overbought": 75, "bb_period": 20, "bb_std": 2.0, "atr_stop": 1.5},
"BNBUSDT": {"rsi_period": 14, "rsi_oversold": 25, "rsi_overbought": 75, "bb_period": 20, "bb_std": 2.0, "atr_stop": 1.5},
"SOLUSDT": {"rsi_period": 14, "rsi_oversold": 25, "rsi_overbought": 75, "bb_period": 20, "bb_std": 2.0, "atr_stop": 1.5},
}
# 多TF参数
MULTI_TF_PARAMS = {
"BTCUSDT": {"fast": 20, "slow": 100, "atr_stop": 2.0},
"ETHUSDT": {"fast": 20, "slow": 100, "atr_stop": 2.0},
"BNBUSDT": {"fast": 20, "slow": 100, "atr_stop": 2.0},
"SOLUSDT": {"fast": 20, "slow": 100, "atr_stop": 2.0},
}
# 波动突破参数
VOL_BREAK_PARAMS = {
"BTCUSDT": {"atr_period": 14, "squeeze_period": 20, "squeeze_ratio": 0.7, "atr_stop": 2.0},
"ETHUSDT": {"atr_period": 14, "squeeze_period": 20, "squeeze_ratio": 0.7, "atr_stop": 2.0},
"BNBUSDT": {"atr_period": 14, "squeeze_period": 20, "squeeze_ratio": 0.7, "atr_stop": 2.0},
"SOLUSDT": {"atr_period": 14, "squeeze_period": 20, "squeeze_ratio": 0.7, "atr_stop": 2.0},
}
# 成交量参数
VOLUME_PARAMS = {
"BTCUSDT": {"obv_lookback": 20, "vwap_std": 2.0, "atr_stop": 2.0},
"ETHUSDT": {"obv_lookback": 20, "vwap_std": 2.0, "atr_stop": 2.0},
"BNBUSDT": {"obv_lookback": 20, "vwap_std": 2.0, "atr_stop": 2.0},
"SOLUSDT": {"obv_lookback": 20, "vwap_std": 2.0, "atr_stop": 2.0},
}
async def run_one(engine_factory, strategy_cls, config_cls, params, symbol, interval, start, end):
"""运行单组回测"""
INITIAL = 10_000.0
sc = config_cls(symbol=symbol, **params)
bt = BacktestConfig(symbol=symbol, interval=interval, start_time=start, end_time=end, initial_capital=INITIAL)
engine = engine_factory(bt)
r = await engine.run(strategy_cls, sc)
m = r.metrics
return m, INITIAL, m.final_equity
async def main():
ds = DataService(config.db)
await ds.connect()
# 预加载所有数据范围
ranges: dict[str, dict[str, tuple[datetime, datetime]]] = {}
for interval in INTERVALS:
ranges[interval] = {}
for symbol in SYMBOLS:
try:
s, e = await ds.fetch_symbol_date_range(symbol, interval)
ranges[interval][symbol] = (s, e)
except Exception:
pass
await ds.close()
all_results: list[dict] = []
print()
print("" * 135)
print(" 日内策略探索 — 4思路 × 4币种 × 3周期")
print("" * 135)
for strategy_name, (config_cls, strategy_cls) in STRATEGIES.items():
print(f"\n{strategy_name}")
print(f" {'币种':<10} {'周期':<6} {'本金':>7} {'终值':>9} {'总收益%':>8} {'年化%':>8} {'夏普':>7} {'回撤%':>8} {'交易':>6} {'耗时s':>7}")
print(" " + "" * 115)
for interval in INTERVALS:
for symbol in SYMBOLS:
if symbol not in ranges.get(interval, {}):
continue
start, end = ranges[interval][symbol]
# 选择参数
if strategy_name == "1.均值回归":
params = MEAN_REV_PARAMS[symbol]
elif strategy_name == "2.多TF(4h+1h)":
params = MULTI_TF_PARAMS[symbol]
elif strategy_name == "3.波动突破":
params = VOL_BREAK_PARAMS[symbol]
else:
params = VOLUME_PARAMS[symbol]
t0 = time.time()
try:
m, initial, final_equity = await run_one(
lambda bt: LongShortEngine(bt, db_config=config.db),
strategy_cls, config_cls, params,
symbol, interval, start, end,
)
elapsed = time.time() - t0
except Exception as ex:
print(f" {symbol:<10} {interval:<6} {'错误: ' + str(ex)[:40]}")
continue
print(f" {symbol:<10} {interval:<6} {initial:>7.0f} {final_equity:>9.0f} {m.total_return_pct:>7.1f}% {m.annual_return_pct:>7.1f}% {m.sharpe_ratio:>7.2f} {m.max_drawdown_pct:>7.1f}% {m.total_trades:>6} {elapsed:>6.1f}s")
all_results.append({
"strategy": strategy_name, "interval": interval, "symbol": symbol,
"return": m.total_return_pct, "annual": m.annual_return_pct,
"sharpe": m.sharpe_ratio, "dd": m.max_drawdown_pct,
"trades": m.total_trades, "initial": initial, "final": final_equity,
})
# ── 汇总:每种策略的最佳组合 ──
print(f"\n\n ■ 各策略最佳组合 (按夏普排名)")
print(f" {'策略':<18} {'级别':<6} {'币种':<10} {'本金':>7} {'终值':>9} {'总收益%':>8} {'年化%':>8} {'夏普':>7} {'回撤%':>8} {'交易':>6}")
print(" " + "" * 115)
# 每种策略取最佳
for sn in STRATEGIES:
candidates = [r for r in all_results if r["strategy"] == sn]
if not candidates:
continue
best = max(candidates, key=lambda x: x["sharpe"])
print(f" {sn:<18} {best['interval']:<6} {best['symbol']:<10} {best['initial']:>7.0f} {best['final']:>9.0f} {best['return']:>7.1f}% {best['annual']:>7.1f}% {best['sharpe']:>7.2f} {best['dd']:>7.1f}% {best['trades']:>6}")
print("\n" * 135)
if __name__ == "__main__":
asyncio.run(main())