2026-06-08 16:26:54 -04:00
|
|
|
"""
|
2026-06-08 16:42:38 -04:00
|
|
|
Standalone TradeLocker Studio strategy for EUR/USD 15m.
|
2026-06-08 16:26:54 -04:00
|
|
|
|
|
|
|
|
Paste this entire file into the single code window in TradeLocker Studio.
|
|
|
|
|
It avoids local project imports and only depends on Backtrader.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import backtrader as bt
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MACDStrategy(bt.Strategy):
|
2026-06-08 16:42:38 -04:00
|
|
|
"""MACD strategy with trend, RSI, ATR exits, and cooldown protection."""
|
2026-06-08 16:26:54 -04:00
|
|
|
|
|
|
|
|
params = dict(
|
|
|
|
|
macd1=10,
|
|
|
|
|
macd2=26,
|
|
|
|
|
macd3=9,
|
2026-06-08 16:42:38 -04:00
|
|
|
fast_ema_period=50,
|
|
|
|
|
slow_ema_period=200,
|
|
|
|
|
rsi_period=14,
|
|
|
|
|
rsi_long_min=58,
|
2026-06-09 07:18:52 -04:00
|
|
|
rsi_long_max=68,
|
|
|
|
|
rsi_short_min=32,
|
2026-06-08 16:42:38 -04:00
|
|
|
rsi_short_max=42,
|
|
|
|
|
atr_period=14,
|
2026-06-09 07:18:52 -04:00
|
|
|
atr_stop_mult=2.0,
|
|
|
|
|
atr_take_mult=3.5,
|
|
|
|
|
cooldown_bars=12,
|
|
|
|
|
min_hold_bars=8,
|
2026-06-08 16:42:38 -04:00
|
|
|
trend_buffer_atr=0.10,
|
|
|
|
|
macd_hist_buffer=0.00005,
|
2026-06-09 07:18:52 -04:00
|
|
|
min_ema_gap_pct=0.0008,
|
|
|
|
|
min_atr_pct=0.00035,
|
|
|
|
|
avoid_friday_after_hour=16,
|
|
|
|
|
avoid_monday_before_hour=3,
|
|
|
|
|
v2_rsi_long_min=52,
|
|
|
|
|
v2_rsi_long_max=64,
|
|
|
|
|
v2_rsi_short_min=36,
|
|
|
|
|
v2_rsi_short_max=48,
|
|
|
|
|
v2_atr_stop_mult=2.4,
|
|
|
|
|
v2_atr_take_mult=2.8,
|
|
|
|
|
v2_cooldown_bars=18,
|
|
|
|
|
v2_min_hold_bars=10,
|
|
|
|
|
v2_min_ema_gap_pct=0.0012,
|
|
|
|
|
v2_min_atr_pct=0.00040,
|
|
|
|
|
breakout_lookback=6,
|
|
|
|
|
max_fast_distance_atr=1.35,
|
|
|
|
|
min_hist_slope=0.00001,
|
|
|
|
|
breakeven_after_atr=1.0,
|
|
|
|
|
trail_after_atr=1.5,
|
|
|
|
|
trail_atr_mult=1.0,
|
|
|
|
|
v3_atr_stop_mult=2.1,
|
|
|
|
|
v3_atr_take_mult=3.0,
|
|
|
|
|
v3_cooldown_bars=24,
|
|
|
|
|
v3_min_hold_bars=16,
|
|
|
|
|
use_fast_trend_exit=False,
|
|
|
|
|
exit_buffer_atr=0.35,
|
|
|
|
|
max_trend_exit_loss_atr=0.75,
|
|
|
|
|
v4_entry_start_hour=13,
|
|
|
|
|
v4_entry_end_hour=17,
|
2026-06-08 16:42:38 -04:00
|
|
|
trade_lot_size=0.01,
|
2026-06-08 16:26:54 -04:00
|
|
|
printlog=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def log(self, txt):
|
|
|
|
|
"""Print a timestamped log line when logging is enabled."""
|
|
|
|
|
if self.p.printlog:
|
|
|
|
|
dt = self.data.datetime.datetime(0)
|
|
|
|
|
print(f"{dt.isoformat()} - {txt}")
|
|
|
|
|
|
2026-06-09 07:18:52 -04:00
|
|
|
def _is_trade_window(self):
|
|
|
|
|
"""Avoid fresh entries around the weekend open/close dead zones."""
|
|
|
|
|
dt = self.data.datetime.datetime(0)
|
|
|
|
|
weekday = dt.weekday()
|
|
|
|
|
|
|
|
|
|
if weekday >= 5:
|
|
|
|
|
return False
|
|
|
|
|
if weekday == 4 and dt.hour >= self.p.avoid_friday_after_hour:
|
|
|
|
|
return False
|
|
|
|
|
if weekday == 0 and dt.hour < self.p.avoid_monday_before_hour:
|
|
|
|
|
return False
|
|
|
|
|
if dt.hour < self.p.v4_entry_start_hour or dt.hour > self.p.v4_entry_end_hour:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
2026-06-08 16:26:54 -04:00
|
|
|
def __init__(self):
|
|
|
|
|
self.macd = bt.indicators.MACD(
|
|
|
|
|
self.data.close,
|
|
|
|
|
period_me1=self.p.macd1,
|
|
|
|
|
period_me2=self.p.macd2,
|
|
|
|
|
period_signal=self.p.macd3,
|
|
|
|
|
)
|
2026-06-08 16:42:38 -04:00
|
|
|
self.fast_ema = bt.indicators.EMA(self.data.close, period=self.p.fast_ema_period)
|
|
|
|
|
self.slow_ema = bt.indicators.EMA(self.data.close, period=self.p.slow_ema_period)
|
|
|
|
|
self.rsi = bt.indicators.RSI(self.data.close, period=self.p.rsi_period)
|
|
|
|
|
self.atr = bt.indicators.ATR(self.data, period=self.p.atr_period)
|
2026-06-08 16:26:54 -04:00
|
|
|
|
|
|
|
|
self.order = None
|
|
|
|
|
self.entry_price = None
|
2026-06-08 16:42:38 -04:00
|
|
|
self.stop_price = None
|
|
|
|
|
self.take_profit_price = None
|
|
|
|
|
self.entry_bar = None
|
|
|
|
|
self.last_exit_bar = -10**9
|
2026-06-08 16:26:54 -04:00
|
|
|
|
|
|
|
|
def next(self):
|
2026-06-08 16:42:38 -04:00
|
|
|
"""Evaluate signals and manage any open trade."""
|
2026-06-08 16:26:54 -04:00
|
|
|
if self.order:
|
|
|
|
|
return
|
|
|
|
|
|
2026-06-08 16:42:38 -04:00
|
|
|
# Let the indicators warm up properly.
|
|
|
|
|
warmup = max(
|
|
|
|
|
self.p.slow_ema_period,
|
|
|
|
|
self.p.macd2,
|
|
|
|
|
self.p.atr_period,
|
2026-06-09 07:18:52 -04:00
|
|
|
self.p.breakout_lookback,
|
2026-06-08 16:42:38 -04:00
|
|
|
) + 5
|
|
|
|
|
if len(self) < warmup:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Avoid rapid re-entry after a close.
|
2026-06-09 07:18:52 -04:00
|
|
|
if len(self) - self.last_exit_bar <= self.p.v3_cooldown_bars:
|
2026-06-08 16:26:54 -04:00
|
|
|
return
|
|
|
|
|
|
|
|
|
|
macd_now = self.macd.macd[0]
|
|
|
|
|
signal_now = self.macd.signal[0]
|
|
|
|
|
macd_prev = self.macd.macd[-1]
|
|
|
|
|
signal_prev = self.macd.signal[-1]
|
2026-06-09 07:18:52 -04:00
|
|
|
# TradeLocker Studio's Backtrader build may not expose self.macd.histo.
|
|
|
|
|
hist_now = float(macd_now - signal_now)
|
|
|
|
|
hist_prev = float(macd_prev - signal_prev)
|
2026-06-08 16:26:54 -04:00
|
|
|
|
|
|
|
|
crossed_up = macd_prev <= signal_prev and macd_now > signal_now
|
|
|
|
|
crossed_down = macd_prev >= signal_prev and macd_now < signal_now
|
2026-06-08 16:42:38 -04:00
|
|
|
hist_rising = hist_now > hist_prev
|
|
|
|
|
hist_falling = hist_now < hist_prev
|
2026-06-09 07:18:52 -04:00
|
|
|
hist_slope = hist_now - hist_prev
|
2026-06-08 16:26:54 -04:00
|
|
|
|
|
|
|
|
price = float(self.data.close[0])
|
2026-06-08 16:42:38 -04:00
|
|
|
fast_ema = float(self.fast_ema[0])
|
|
|
|
|
slow_ema = float(self.slow_ema[0])
|
|
|
|
|
rsi = float(self.rsi[0])
|
|
|
|
|
atr = float(self.atr[0]) if float(self.atr[0]) > 0 else 0.0
|
2026-06-09 07:18:52 -04:00
|
|
|
prior_high = max(float(self.data.high[-idx]) for idx in range(1, self.p.breakout_lookback + 1))
|
|
|
|
|
prior_low = min(float(self.data.low[-idx]) for idx in range(1, self.p.breakout_lookback + 1))
|
2026-06-08 16:42:38 -04:00
|
|
|
|
|
|
|
|
trend_buffer = atr * self.p.trend_buffer_atr
|
|
|
|
|
bullish_trend = price > slow_ema + trend_buffer and fast_ema > slow_ema + trend_buffer
|
|
|
|
|
bearish_trend = price < slow_ema - trend_buffer and fast_ema < slow_ema - trend_buffer
|
2026-06-09 07:18:52 -04:00
|
|
|
ema_gap_pct = abs(fast_ema - slow_ema) / price if price else 0.0
|
|
|
|
|
atr_pct = atr / price if price else 0.0
|
|
|
|
|
enough_trend = ema_gap_pct >= self.p.v2_min_ema_gap_pct
|
|
|
|
|
enough_volatility = atr_pct >= self.p.v2_min_atr_pct
|
|
|
|
|
fast_distance_atr = abs(price - fast_ema) / atr if atr else 999.0
|
|
|
|
|
not_chasing = fast_distance_atr <= self.p.max_fast_distance_atr
|
|
|
|
|
long_confirmation = price > prior_high
|
|
|
|
|
short_confirmation = price < prior_low
|
2026-06-08 16:26:54 -04:00
|
|
|
|
|
|
|
|
if not self.position:
|
2026-06-09 07:18:52 -04:00
|
|
|
if not self._is_trade_window():
|
|
|
|
|
return
|
|
|
|
|
|
2026-06-08 16:42:38 -04:00
|
|
|
long_ok = (
|
2026-06-09 07:18:52 -04:00
|
|
|
(crossed_up or hist_now > self.p.macd_hist_buffer)
|
2026-06-08 16:42:38 -04:00
|
|
|
and hist_now > self.p.macd_hist_buffer
|
|
|
|
|
and hist_rising
|
2026-06-09 07:18:52 -04:00
|
|
|
and hist_slope >= self.p.min_hist_slope
|
2026-06-08 16:42:38 -04:00
|
|
|
and bullish_trend
|
2026-06-09 07:18:52 -04:00
|
|
|
and enough_trend
|
|
|
|
|
and enough_volatility
|
|
|
|
|
and not_chasing
|
|
|
|
|
and long_confirmation
|
|
|
|
|
and self.p.v2_rsi_long_min <= rsi <= self.p.v2_rsi_long_max
|
2026-06-08 16:42:38 -04:00
|
|
|
)
|
|
|
|
|
short_ok = (
|
2026-06-09 07:18:52 -04:00
|
|
|
(crossed_down or hist_now < -self.p.macd_hist_buffer)
|
2026-06-08 16:42:38 -04:00
|
|
|
and hist_now < -self.p.macd_hist_buffer
|
|
|
|
|
and hist_falling
|
2026-06-09 07:18:52 -04:00
|
|
|
and hist_slope <= -self.p.min_hist_slope
|
2026-06-08 16:42:38 -04:00
|
|
|
and bearish_trend
|
2026-06-09 07:18:52 -04:00
|
|
|
and enough_trend
|
|
|
|
|
and enough_volatility
|
|
|
|
|
and not_chasing
|
|
|
|
|
and short_confirmation
|
|
|
|
|
and self.p.v2_rsi_short_min <= rsi <= self.p.v2_rsi_short_max
|
2026-06-08 16:42:38 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if long_ok:
|
|
|
|
|
self.log(
|
|
|
|
|
f"LONG signal price={price:.5f} rsi={rsi:.1f} "
|
2026-06-09 07:18:52 -04:00
|
|
|
f"fast_ema={fast_ema:.5f} slow_ema={slow_ema:.5f} "
|
|
|
|
|
f"hist={hist_now:.6f} ema_gap={ema_gap_pct:.4%} "
|
|
|
|
|
f"atr={atr_pct:.4%} fast_dist={fast_distance_atr:.2f}"
|
2026-06-08 16:42:38 -04:00
|
|
|
)
|
|
|
|
|
self.order = self.buy(size=self.p.trade_lot_size)
|
|
|
|
|
elif short_ok:
|
|
|
|
|
self.log(
|
|
|
|
|
f"SHORT signal price={price:.5f} rsi={rsi:.1f} "
|
2026-06-09 07:18:52 -04:00
|
|
|
f"fast_ema={fast_ema:.5f} slow_ema={slow_ema:.5f} "
|
|
|
|
|
f"hist={hist_now:.6f} ema_gap={ema_gap_pct:.4%} "
|
|
|
|
|
f"atr={atr_pct:.4%} fast_dist={fast_distance_atr:.2f}"
|
2026-06-08 16:42:38 -04:00
|
|
|
)
|
|
|
|
|
self.order = self.sell(size=self.p.trade_lot_size)
|
2026-06-08 16:26:54 -04:00
|
|
|
return
|
|
|
|
|
|
2026-06-08 16:42:38 -04:00
|
|
|
# Position management
|
2026-06-08 16:26:54 -04:00
|
|
|
if self.position.size > 0:
|
2026-06-09 07:18:52 -04:00
|
|
|
open_profit_atr = (price - self.entry_price) / atr if self.entry_price and atr else 0.0
|
|
|
|
|
if open_profit_atr >= self.p.breakeven_after_atr:
|
|
|
|
|
self.stop_price = max(self.stop_price or self.entry_price, self.entry_price)
|
|
|
|
|
if open_profit_atr >= self.p.trail_after_atr:
|
|
|
|
|
self.stop_price = max(self.stop_price or self.entry_price, price - atr * self.p.trail_atr_mult)
|
|
|
|
|
|
2026-06-08 16:42:38 -04:00
|
|
|
stop_hit = self.stop_price is not None and price <= self.stop_price
|
|
|
|
|
take_hit = self.take_profit_price is not None and price >= self.take_profit_price
|
|
|
|
|
reversal = crossed_down
|
|
|
|
|
held_bars = len(self) - (self.entry_bar or len(self))
|
2026-06-09 07:18:52 -04:00
|
|
|
can_flip = held_bars >= self.p.v3_min_hold_bars
|
|
|
|
|
max_exit_loss = atr * self.p.max_trend_exit_loss_atr if atr else 0.0
|
|
|
|
|
trend_break = (
|
|
|
|
|
self.p.use_fast_trend_exit
|
|
|
|
|
and can_flip
|
|
|
|
|
and price < fast_ema - atr * self.p.exit_buffer_atr
|
|
|
|
|
and self.entry_price is not None
|
|
|
|
|
and price >= self.entry_price - max_exit_loss
|
|
|
|
|
)
|
|
|
|
|
reversal_exit = (
|
|
|
|
|
can_flip
|
|
|
|
|
and reversal
|
|
|
|
|
and price < fast_ema - atr * self.p.exit_buffer_atr
|
|
|
|
|
and self.entry_price is not None
|
|
|
|
|
and price >= self.entry_price
|
|
|
|
|
)
|
2026-06-08 16:42:38 -04:00
|
|
|
|
2026-06-09 07:18:52 -04:00
|
|
|
if stop_hit or take_hit or trend_break or reversal_exit:
|
2026-06-08 16:42:38 -04:00
|
|
|
reason = (
|
2026-06-09 07:18:52 -04:00
|
|
|
"bearish crossover" if reversal_exit else
|
2026-06-08 16:42:38 -04:00
|
|
|
"trend break" if trend_break else
|
|
|
|
|
"stop loss" if stop_hit else
|
|
|
|
|
"take profit"
|
|
|
|
|
)
|
|
|
|
|
self.log(f"Exit long on {reason} at {price:.5f}")
|
2026-06-08 16:26:54 -04:00
|
|
|
self.order = self.close()
|
|
|
|
|
|
|
|
|
|
elif self.position.size < 0:
|
2026-06-09 07:18:52 -04:00
|
|
|
open_profit_atr = (self.entry_price - price) / atr if self.entry_price and atr else 0.0
|
|
|
|
|
if open_profit_atr >= self.p.breakeven_after_atr:
|
|
|
|
|
self.stop_price = min(self.stop_price or self.entry_price, self.entry_price)
|
|
|
|
|
if open_profit_atr >= self.p.trail_after_atr:
|
|
|
|
|
self.stop_price = min(self.stop_price or self.entry_price, price + atr * self.p.trail_atr_mult)
|
|
|
|
|
|
2026-06-08 16:42:38 -04:00
|
|
|
stop_hit = self.stop_price is not None and price >= self.stop_price
|
|
|
|
|
take_hit = self.take_profit_price is not None and price <= self.take_profit_price
|
|
|
|
|
reversal = crossed_up
|
|
|
|
|
held_bars = len(self) - (self.entry_bar or len(self))
|
2026-06-09 07:18:52 -04:00
|
|
|
can_flip = held_bars >= self.p.v3_min_hold_bars
|
|
|
|
|
max_exit_loss = atr * self.p.max_trend_exit_loss_atr if atr else 0.0
|
|
|
|
|
trend_break = (
|
|
|
|
|
self.p.use_fast_trend_exit
|
|
|
|
|
and can_flip
|
|
|
|
|
and price > fast_ema + atr * self.p.exit_buffer_atr
|
|
|
|
|
and self.entry_price is not None
|
|
|
|
|
and price <= self.entry_price + max_exit_loss
|
|
|
|
|
)
|
|
|
|
|
reversal_exit = (
|
|
|
|
|
can_flip
|
|
|
|
|
and reversal
|
|
|
|
|
and price > fast_ema + atr * self.p.exit_buffer_atr
|
|
|
|
|
and self.entry_price is not None
|
|
|
|
|
and price <= self.entry_price
|
|
|
|
|
)
|
2026-06-08 16:42:38 -04:00
|
|
|
|
2026-06-09 07:18:52 -04:00
|
|
|
if stop_hit or take_hit or trend_break or reversal_exit:
|
2026-06-08 16:42:38 -04:00
|
|
|
reason = (
|
2026-06-09 07:18:52 -04:00
|
|
|
"bullish crossover" if reversal_exit else
|
2026-06-08 16:42:38 -04:00
|
|
|
"trend break" if trend_break else
|
|
|
|
|
"stop loss" if stop_hit else
|
|
|
|
|
"take profit"
|
|
|
|
|
)
|
|
|
|
|
self.log(f"Exit short on {reason} at {price:.5f}")
|
2026-06-08 16:26:54 -04:00
|
|
|
self.order = self.close()
|
|
|
|
|
|
|
|
|
|
def notify_order(self, order):
|
2026-06-08 16:42:38 -04:00
|
|
|
"""Track order lifecycle and set ATR-based exits on fills."""
|
2026-06-08 16:26:54 -04:00
|
|
|
if order.status in [order.Submitted, order.Accepted]:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if order.status == order.Completed:
|
|
|
|
|
side = "BUY" if order.isbuy() else "SELL"
|
|
|
|
|
self.entry_price = float(order.executed.price)
|
2026-06-08 16:42:38 -04:00
|
|
|
self.entry_bar = len(self)
|
|
|
|
|
atr = float(self.atr[0]) if float(self.atr[0]) > 0 else 0.0
|
|
|
|
|
|
|
|
|
|
if order.isbuy():
|
|
|
|
|
if atr > 0:
|
2026-06-09 07:18:52 -04:00
|
|
|
self.stop_price = self.entry_price - atr * self.p.v3_atr_stop_mult
|
|
|
|
|
self.take_profit_price = self.entry_price + atr * self.p.v3_atr_take_mult
|
2026-06-08 16:42:38 -04:00
|
|
|
else:
|
|
|
|
|
self.stop_price = self.entry_price * (1 - 0.02)
|
|
|
|
|
self.take_profit_price = self.entry_price * (1 + 0.04)
|
|
|
|
|
else:
|
|
|
|
|
if atr > 0:
|
2026-06-09 07:18:52 -04:00
|
|
|
self.stop_price = self.entry_price + atr * self.p.v3_atr_stop_mult
|
|
|
|
|
self.take_profit_price = self.entry_price - atr * self.p.v3_atr_take_mult
|
2026-06-08 16:42:38 -04:00
|
|
|
else:
|
|
|
|
|
self.stop_price = self.entry_price * (1 + 0.02)
|
|
|
|
|
self.take_profit_price = self.entry_price * (1 - 0.04)
|
|
|
|
|
|
|
|
|
|
self.log(
|
|
|
|
|
f"{side} EXECUTED price={order.executed.price:.5f} "
|
|
|
|
|
f"size={order.executed.size} stop={self.stop_price:.5f} tp={self.take_profit_price:.5f}"
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-08 16:26:54 -04:00
|
|
|
elif order.status in [order.Canceled, order.Margin, order.Rejected]:
|
|
|
|
|
self.log("Order Canceled/Margin/Rejected")
|
|
|
|
|
|
|
|
|
|
self.order = None
|
|
|
|
|
|
|
|
|
|
def notify_trade(self, trade):
|
|
|
|
|
"""Print trade PnL when a trade closes."""
|
|
|
|
|
if trade.isclosed:
|
2026-06-08 16:42:38 -04:00
|
|
|
self.last_exit_bar = len(self)
|
|
|
|
|
self.stop_price = None
|
|
|
|
|
self.take_profit_price = None
|
|
|
|
|
self.entry_price = None
|
|
|
|
|
self.entry_bar = None
|
2026-06-08 16:26:54 -04:00
|
|
|
self.log(f"TRADE CLOSED pnl={trade.pnl:.2f} pnlcomm={trade.pnlcomm:.2f}")
|
|
|
|
|
|
|
|
|
|
def stop(self):
|
|
|
|
|
"""Final summary when the backtest or live session stops."""
|
|
|
|
|
self.log(f"Final portfolio value={self.broker.getvalue():.2f}")
|