Files
fx-quant/src/strategies_pkg/s13_bollinger_keltner_squeeze.py
T
Brent NealeandClaude Opus 4.6 7657d4b1b3 Add 5 new strategies (S12-S16) — all tested, none show edge on M15
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>
2026-02-21 06:57:29 +10:00

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,
}