mirror of
https://github.com/BrentNeale1/fx-quant.git
synced 2026-08-19 13:08:07 +00:00
Implemented and backtested 5 new strategies adapted to M15 timeframe: - S12 Asian Range Sweep (best OOS PF 0.66) - S13 Bollinger-Keltner Squeeze (best OOS PF 0.79) - S14 London Fix post-fix reversal (best OOS PF 0.52) - S15 Momentum Continuation (best OOS PF 0.87) - S16 London ORB (best OOS PF 0.63) Added Bollinger Bands and Keltner Channels to indicator pipeline. None passed generalization — these strategies need M5/M30 data. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
176 lines
5.8 KiB
Python
176 lines
5.8 KiB
Python
"""
|
|
Strategy S13: Bollinger-Keltner Squeeze.
|
|
|
|
Concept: When Bollinger Bands (20, 2) compress inside Keltner Channels (20, 1.5),
|
|
volatility is contracting. When BBs expand back outside KC, a directional move
|
|
is starting. Also known as "TTM Squeeze" (John Carter).
|
|
|
|
Adapted to M15 from original 30-minute specification.
|
|
|
|
Entry conditions (ALL must be true):
|
|
1. Squeeze detected: BB was inside KC for at least 3 bars (squeeze on)
|
|
2. Squeeze releases: BB expands outside KC (squeeze off)
|
|
3. Momentum direction determines trade direction (MACD histogram)
|
|
4. ADX rising (trend developing)
|
|
5. Session: 07:00-17:00 UTC (London + NY)
|
|
6. HTF trend alignment (confluence)
|
|
|
|
Exit:
|
|
- SL: 1.5x ATR from entry
|
|
- TP1: 2.0x ATR from entry (close 50%)
|
|
- TP2: 3.0x ATR from entry (close 50%)
|
|
- Max hold: 40 bars (~10 hours on M15)
|
|
"""
|
|
from typing import Optional
|
|
import numpy as np
|
|
import pandas as pd
|
|
from .base import BaseStrategy
|
|
|
|
|
|
class S13_BollingerKeltnerSqueeze(BaseStrategy):
|
|
strategy_id = 13
|
|
name = "S13_BollingerKeltnerSqueeze"
|
|
|
|
# Squeeze detection
|
|
MIN_SQUEEZE_BARS = 3 # Min bars in squeeze before release
|
|
MAX_SQUEEZE_BARS = 30 # Max bars in squeeze (too long = no energy)
|
|
|
|
# Risk management
|
|
SL_ATR_MULT = 1.5
|
|
TP1_ATR_MULT = 2.0
|
|
TP2_ATR_MULT = 3.0
|
|
TRAIL_ATR_MULT = 1.0
|
|
|
|
# Filters
|
|
MIN_ADX = 15 # Minimum ADX for directional move
|
|
SESSION_START = 7
|
|
SESSION_END = 17
|
|
|
|
MAX_BARS = 40
|
|
|
|
def _is_squeeze_on(self, row):
|
|
"""Check if BB is inside KC (squeeze is on)."""
|
|
bb_upper = row.get("bb_upper", np.nan)
|
|
bb_lower = row.get("bb_lower", np.nan)
|
|
kc_upper = row.get("kc_upper", np.nan)
|
|
kc_lower = row.get("kc_lower", np.nan)
|
|
|
|
if any(np.isnan(v) for v in [bb_upper, bb_lower, kc_upper, kc_lower]):
|
|
return False
|
|
|
|
return bb_upper < kc_upper and bb_lower > kc_lower
|
|
|
|
def check_signal(self, data: pd.DataFrame, idx: int,
|
|
current: pd.Series,
|
|
htf_row: Optional[pd.Series] = None) -> Optional[dict]:
|
|
if idx < 200:
|
|
return None
|
|
|
|
# Session filter
|
|
hour = current.name.hour if hasattr(current.name, 'hour') else 0
|
|
if hour < self.SESSION_START or hour >= self.SESSION_END:
|
|
return None
|
|
|
|
atr_val = current.get("atr_14", 0)
|
|
if atr_val <= 0 or np.isnan(atr_val):
|
|
return None
|
|
|
|
# Check if squeeze just released (was on, now off)
|
|
current_squeeze = self._is_squeeze_on(current)
|
|
if current_squeeze:
|
|
return None # Still in squeeze
|
|
|
|
# Count how many prior bars were in squeeze
|
|
squeeze_count = 0
|
|
for i in range(idx - 1, max(0, idx - self.MAX_SQUEEZE_BARS - 1), -1):
|
|
if self._is_squeeze_on(data.iloc[i]):
|
|
squeeze_count += 1
|
|
else:
|
|
break
|
|
|
|
if squeeze_count < self.MIN_SQUEEZE_BARS:
|
|
return None
|
|
|
|
# Also verify the bar before the squeeze run was NOT in squeeze
|
|
# (ensures we detect the release, not a mid-squeeze fluctuation)
|
|
pre_squeeze_idx = idx - 1 - squeeze_count
|
|
if pre_squeeze_idx >= 0 and self._is_squeeze_on(data.iloc[pre_squeeze_idx]):
|
|
return None # Squeeze was already going before our count window
|
|
|
|
# Momentum direction: use MACD histogram
|
|
macd_hist = current.get("macd_hist", 0)
|
|
if np.isnan(macd_hist) or macd_hist == 0:
|
|
return None
|
|
|
|
# Also check momentum is accelerating (current > previous)
|
|
prev_hist = data.iloc[idx - 1].get("macd_hist", 0)
|
|
if np.isnan(prev_hist):
|
|
prev_hist = 0
|
|
|
|
direction = None
|
|
if macd_hist > 0 and macd_hist > prev_hist:
|
|
direction = "LONG"
|
|
elif macd_hist < 0 and macd_hist < prev_hist:
|
|
direction = "SHORT"
|
|
|
|
if direction is None:
|
|
return None
|
|
|
|
# ADX filter: trend developing
|
|
adx_val = current.get("adx_14", 0)
|
|
if np.isnan(adx_val):
|
|
adx_val = 0
|
|
if adx_val < self.MIN_ADX:
|
|
return None
|
|
|
|
# ADX rising check
|
|
prev_adx = data.iloc[idx - 1].get("adx_14", 0)
|
|
if np.isnan(prev_adx):
|
|
prev_adx = 0
|
|
adx_rising = adx_val > prev_adx
|
|
|
|
price = current["close"]
|
|
|
|
# HTF alignment
|
|
htf_aligned = False
|
|
if htf_row is not None:
|
|
htf_ema200 = htf_row.get("ema_200", np.nan)
|
|
htf_close = htf_row.get("close", np.nan)
|
|
if not np.isnan(htf_ema200) and not np.isnan(htf_close):
|
|
if direction == "LONG" and htf_close > htf_ema200:
|
|
htf_aligned = True
|
|
elif direction == "SHORT" and htf_close < htf_ema200:
|
|
htf_aligned = True
|
|
|
|
# Confluence scoring
|
|
confluence = 3 # Base: squeeze release + momentum + ADX
|
|
if htf_aligned:
|
|
confluence += 1
|
|
if adx_rising:
|
|
confluence += 1
|
|
if squeeze_count >= 6:
|
|
confluence += 1 # Longer squeeze = more stored energy
|
|
|
|
# SL / TP
|
|
if direction == "LONG":
|
|
sl = price - self.SL_ATR_MULT * atr_val
|
|
tp1 = price + self.TP1_ATR_MULT * atr_val
|
|
tp2 = price + self.TP2_ATR_MULT * atr_val
|
|
else:
|
|
sl = price + self.SL_ATR_MULT * atr_val
|
|
tp1 = price - self.TP1_ATR_MULT * atr_val
|
|
tp2 = price - self.TP2_ATR_MULT * atr_val
|
|
|
|
return {
|
|
"direction": direction,
|
|
"sl": sl,
|
|
"tp1": tp1,
|
|
"tp2": tp2,
|
|
"tp3": tp2,
|
|
"confluence": confluence,
|
|
"entry_pattern": f"squeeze_release_{direction.lower()}",
|
|
"tp_splits": (0.50, 0.50, 0.0),
|
|
"trail_atr_mult": self.TRAIL_ATR_MULT,
|
|
"max_bars": self.MAX_BARS,
|
|
}
|