Timeframe
5m
Direction
Long & Short
Stoploss
-30.0%
Trailing Stop
No
ROI
N/A
Interface Version
N/A
Startup Candles
300
Indicators
0
freqtrade/freqtrade-strategies
author@: lenik
import logging
import numpy as np
import pandas as pd
from datetime import datetime
from pandas import DataFrame
from freqtrade.strategy import IStrategy, CategoricalParameter, DecimalParameter
from freqtrade.persistence import Trade
logger = logging.getLogger(__name__)
NY_TZ = 'America/New_York'
RANGE_START_HOUR = 0 # 04:00 NY inclusive
RANGE_END_HOUR = 4 # 08:00 NY exclusive — trading window starts at 08:00 NY
RR_RATIO = 2.0 # Risk:Reward = 1:2
class SekkaFour(IStrategy):
"""
NY-session Range Breakout/Re-entry Strategy.
1. Build daily range from 04:00–08:00 New York time (HP, LP).
2. After 08:00 NY: if a 5m candle closes above HP, arm short.
When a later 5m candle closes back below HP, enter short.
SL = highest close between break and re-cross. TP = entry - 2 * (SL - entry).
3. Mirror for LP / long.
4. Setups discarded at NY midnight; exits may run past midnight.
5. One open position per pair. Multiple trades per day allowed once previous closes.
"""
timeframe = '5m'
can_short = True
# Shorts: SL via custom_stoploss (dynamic), TP via custom_exit (1:2 RR).
# Longs: SL via this static stoploss config, TP via custom_exit (1:2 RR using dynamic SL ref).
minimal_roi = {}
stoploss = -0.30
trailing_stop = False
use_custom_stoploss = True
use_exit_signal = True
exit_profit_only = False
process_only_new_candles = True
startup_candle_count = 300
# --- Hyperopt Parameters ---
range_basis = CategoricalParameter(
['wick', 'close'], default='wick', space='buy', optimize=True
)
# Fraction of (HP - LP) above which the breakout magnitude is considered "too large".
# When triggered, SL falls back to the nearest fractal swing close (in price) within
# the breakout phase. Trade is skipped if no qualifying fractal is found.
large_breakout_threshold = DecimalParameter(
0.50, 0.95, default=0.80, decimals=2, space='buy', optimize=True
)
def _compute_signals(self, df: DataFrame, basis: str, threshold: float) -> DataFrame:
"""
Adds columns:
ny_date, HP, LP,
enter_long, enter_short, enter_tag,
long_sl_price, short_sl_price
"""
df = df.copy()
ny_dt = df['date'].dt.tz_convert(NY_TZ)
df['ny_date'] = ny_dt.dt.date
ny_hour = ny_dt.dt.hour
in_range = (ny_hour >= RANGE_START_HOUR) & (ny_hour < RANGE_END_HOUR)
range_df = df[in_range]
if basis == 'wick':
day_hp = range_df.groupby('ny_date')['high'].max()
day_lp = range_df.groupby('ny_date')['low'].min()
else:
day_hp = range_df.groupby('ny_date')['close'].max()
day_lp = range_df.groupby('ny_date')['close'].min()
df['HP'] = df['ny_date'].map(day_hp)
df['LP'] = df['ny_date'].map(day_lp)
n = len(df)
closes = df['close'].to_numpy()
highs = df['high'].to_numpy()
lows = df['low'].to_numpy()
opens = df['open'].to_numpy()
# next_opens[i] = open of candle i+1 (the candle the entry would fill on).
# NaN at the last row — in live mode the next candle hasn't formed yet, so
# the inline filter defers to confirm_trade_entry.
next_opens = np.full(n, np.nan)
if n > 1:
next_opens[:-1] = opens[1:]
# 5-candle fractal flags on closes (confirmed only when k+2 has occurred).
swing_high = np.zeros(n, dtype=bool)
swing_low = np.zeros(n, dtype=bool)
for k in range(2, n - 2):
ck = closes[k]
if (ck > closes[k - 1] and ck > closes[k - 2]
and ck > closes[k + 1] and ck > closes[k + 2]):
swing_high[k] = True
if (ck < closes[k - 1] and ck < closes[k - 2]
and ck < closes[k + 1] and ck < closes[k + 2]):
swing_low[k] = True
enter_long = np.zeros(n, dtype=np.int8)
enter_short = np.zeros(n, dtype=np.int8)
long_sl = np.full(n, np.nan)
short_sl = np.full(n, np.nan)
tags = np.empty(n, dtype=object)
ny_dates = df['ny_date'].to_numpy()
ny_hours = ny_hour.to_numpy()
hps = df['HP'].to_numpy()
lps = df['LP'].to_numpy()
current_date = None
hp_broken = False
lp_broken = False
running_max_close = -np.inf
running_min_close = np.inf
running_max_high = -np.inf
running_min_low = np.inf
hp_break_start = -1
lp_break_start = -1
for i in range(n):
d = ny_dates[i]
if d != current_date:
current_date = d
hp_broken = False
lp_broken = False
running_max_close = -np.inf
running_min_close = np.inf
running_max_high = -np.inf
running_min_low = np.inf
hp_break_start = -1
lp_break_start = -1
if ny_hours[i] < RANGE_END_HOUR:
continue
hp = hps[i]
lp = lps[i]
if np.isnan(hp) or np.isnan(lp):
continue
c = closes[i]
range_size = hp - lp
# --- HP / short side ---
if c > hp:
if not hp_broken:
hp_break_start = i
hp_broken = True
if c > running_max_close:
running_max_close = c
if highs[i] > running_max_high:
running_max_high = highs[i]
elif hp_broken and c < hp:
breakout_mag = running_max_close - hp
sl_value = running_max_high
if range_size > 0 and breakout_mag > threshold * range_size:
# Breakout too large — fall back to nearest-in-price fractal swing
# high close strictly between HP and running_max_close.
fallback = np.nan
lowest = np.inf
# k in [hp_break_start, i-2] inclusive (fractals confirmed by candle i)
for k in range(hp_break_start, i - 1):
if swing_high[k]:
ck = closes[k]
if hp < ck < running_max_close and ck < lowest:
lowest = ck
fallback = ck
sl_value = fallback # NaN if no fractal — trade skipped
if np.isnan(sl_value):
# Untradeable break (no valid fractal). Reset state — don't retry.
hp_broken = False
running_max_close = -np.inf
running_max_high = -np.inf
hp_break_start = -1
else:
next_open = next_opens[i]
# Fakeout filter: only emit if N+1 opens below HP. NaN means the
# next candle isn't known yet (live edge) — defer to confirm_trade_entry.
if np.isnan(next_open) or next_open < hp:
enter_short[i] = 1
short_sl[i] = sl_value
tags[i] = 'four_short_hp'
hp_broken = False
running_max_close = -np.inf
running_max_high = -np.inf
hp_break_start = -1
# else: fakeout — keep state so the next candle can retry the same break.
# --- LP / long side ---
if c < lp:
if not lp_broken:
lp_break_start = i
lp_broken = True
if c < running_min_close:
running_min_close = c
if lows[i] < running_min_low:
running_min_low = lows[i]
elif lp_broken and c > lp:
breakout_mag = lp - running_min_close
sl_value = running_min_low
if range_size > 0 and breakout_mag > threshold * range_size:
# Breakout too large — fall back to nearest-in-price fractal swing
# low close strictly between running_min_close and LP.
fallback = np.nan
highest = -np.inf
for k in range(lp_break_start, i - 1):
if swing_low[k]:
ck = closes[k]
if running_min_close < ck < lp and ck > highest:
highest = ck
fallback = ck
sl_value = fallback
if np.isnan(sl_value):
lp_broken = False
running_min_close = np.inf
running_min_low = np.inf
lp_break_start = -1
else:
next_open = next_opens[i]
# Fakeout filter: only emit if N+1 opens above LP. NaN means the
# next candle isn't known yet (live edge) — defer to confirm_trade_entry.
if np.isnan(next_open) or next_open > lp:
enter_long[i] = 1
long_sl[i] = sl_value
tags[i] = 'four_long_lp'
lp_broken = False
running_min_close = np.inf
running_min_low = np.inf
lp_break_start = -1
# else: fakeout — keep state so the next candle can retry the same break.
df['enter_long'] = enter_long
df['enter_short'] = enter_short
df['long_sl_price'] = long_sl
df['short_sl_price'] = short_sl
df['enter_tag'] = tags
return df
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
return self._compute_signals(
dataframe,
self.range_basis.value,
self.large_breakout_threshold.value,
)
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
# Signals already populated in indicators; nothing to do here.
return dataframe
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
return dataframe
def leverage(self, pair: str, current_time: datetime, current_rate: float,
proposed_leverage: float, max_leverage: float, side: str, **kwargs) -> float:
return 1.0
def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float,
time_in_force: str, current_time: datetime, entry_tag,
side: str, **kwargs) -> bool:
# Fakeout filter for live mode: when the signal fires on the last candle,
# the inline next_open check in _compute_signals is NaN. Re-check the actual
# fill rate against today's HP/LP and reject if price has popped back across.
df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if df is None or df.empty:
return False
last = df.iloc[-1]
hp = last.get('HP')
lp = last.get('LP')
if pd.isna(hp) or pd.isna(lp):
return False
if side == 'short':
return rate < hp
return rate > lp
def _ensure_trade_levels(self, pair: str, trade: Trade) -> tuple:
"""
Return (sl_price, tp_price), populating trade.custom_data on first call.
"""
sl_price = trade.get_custom_data('sl_price')
tp_price = trade.get_custom_data('tp_price')
if sl_price is not None and tp_price is not None:
return sl_price, tp_price
df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
if df is None or df.empty:
return None, None
col = 'short_sl_price' if trade.is_short else 'long_sl_price'
signal_col = 'enter_short' if trade.is_short else 'enter_long'
prior = df[df['date'] < trade.open_date_utc]
if prior.empty:
return None, None
signals = prior[prior[signal_col] == 1]
if signals.empty:
return None, None
signal_row = signals.iloc[-1]
sl_raw = signal_row[col]
if pd.isna(sl_raw):
return None, None
sl_price = float(sl_raw)
entry = float(trade.open_rate)
if trade.is_short:
tp_price = entry - RR_RATIO * (sl_price - entry)
else:
tp_price = entry + RR_RATIO * (entry - sl_price)
trade.set_custom_data('sl_price', sl_price)
trade.set_custom_data('tp_price', float(tp_price))
return sl_price, float(tp_price)
def custom_stoploss(self, pair: str, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, **kwargs):
# Longs use the static `self.stoploss`; only TP is dynamic for longs.
if not trade.is_short:
return None
sl_price, _ = self._ensure_trade_levels(pair, trade)
if sl_price is None:
return None
return (trade.open_rate - sl_price) / trade.open_rate
def custom_exit(self, pair: str, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float, **kwargs):
_, tp_price = self._ensure_trade_levels(pair, trade)
if tp_price is None:
return None
if trade.is_short and current_rate <= tp_price:
return 'tp_1to2'
if (not trade.is_short) and current_rate >= tp_price:
return 'tp_1to2'
return None