# source: https://raw.githubusercontent.com/labanux/freq/b573987c312315003c6ed9cd1bcf8cd009ec500b/user_data/strategies/sekka-four.py
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 Github_labanux_freq__sekka_four__20260505_044916(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
