# source: https://raw.githubusercontent.com/heart7/botBybit/6e91ec6d27148d7ddd3c0416c4dbaeab178c4425/user_data/strategies/ml_grid_strategy.py
from freqtrade.strategy import IStrategy
try:
    # freqtrade.freqai may vary between versions; import safely
    from freqtrade.freqai.prediction_models import TensorflowRegressor
    from freqtrade.freqai.data_kitchen import FreqaiDataKitchen
except Exception:
    TensorflowRegressor = None
    FreqaiDataKitchen = None
from datetime import datetime
import numpy as np
import pandas as pd
import talib.abstract as ta
from pandas import DataFrame
import requests
import logging

logger = logging.getLogger(__name__)


class Github_heart7_botBybit__ml_grid_strategy__20251012_181051(IStrategy):
    INTERFACE_VERSION = 3
    minimal_roi = {"0": 0.05}
    stoploss = -0.05
    trailing_stop = True
    trailing_stop_positive = 0.02
    timeframe = '1h'
    # Rename numeric default to avoid colliding with the leverage() method
    default_leverage = 2
    grid_levels = 5
    grid_spacing = 0.01
    startup_candle_count: int = 50

    # Default to False to avoid requiring heavy ML dependencies for quick runs.
    # Enable via config (or set to True after installing freqai/TensorFlow).
    freqai_enabled = False
    # Use imported model if available, otherwise disable freqai features at runtime
    freqai_model = TensorflowRegressor if TensorflowRegressor is not None else None
    freqai_corr_pairlist = ['BTC/USDT:USDT']
    freqai_feature_parameters = {
        'window_size': 20,
        'corr_window_size': 20,
    }
    freqai_model_parameters = {
        'epochs': 100,
        'batch_size': 32,
        'optimizer': 'adam',
        'loss': 'mse',
        'metrics': ['mae'],
        'layers': [
            {'type': 'MultiHeadAttention', 'num_heads': 4, 'key_dim': 50},
            {'type': 'Dense', 'units': 64, 'activation': 'relu'},
            {'type': 'Dense', 'units': 1},
        ]
    }
    freqai_retrain_period = 12
    freqai_identifier = 'mlgrid_transformer'

    def __init__(self, config: dict) -> None:
        # IStrategy implementations may be constructed with a config dict.
        # Safely load freqai configuration overrides from the strategy/config file
        # while keeping reasonable defaults when freqai isn't available.
        super().__init__(config)
        try:
            self._load_freqai_config()
        except Exception:
            # Do not fail strategy construction if freqai or config keys are missing
            logger.debug("Failed to load freqai config overrides; using defaults.")

    def _load_freqai_config(self) -> None:
        """Merge freqai-related config from self.config with strategy defaults.

        This allows users to set `freqai_enabled` and `freqai_model_parameters`
        in `config.json` (top-level or under strategy-specific keys) without
        requiring code changes.
        """
        cfg = getattr(self, 'config', None) or {}

        # Top-level config option: freqai_enabled
        if isinstance(cfg, dict):
            top_enabled = cfg.get('freqai_enabled')
            if isinstance(top_enabled, bool):
                self.freqai_enabled = top_enabled

            # Strategy-specific overrides: config['strategy'] or config['strategy_config']
            strat_cfg = cfg.get('strategy') if isinstance(cfg.get('strategy'), dict) else {}
            if not strat_cfg:
                strat_cfg = cfg.get('strategy_config') if isinstance(cfg.get('strategy_config'), dict) else {}

            # Some users may put strategy options under strategy_name -> options
            # e.g., config['strategy']['Github_heart7_botBybit__ml_grid_strategy__20251012_181051'] = {...}
            strat_name_cfg = {}
            if isinstance(strat_cfg, dict):
                strat_name_cfg = strat_cfg.get(self.__class__.__name__, {}) if isinstance(strat_cfg.get(self.__class__.__name__), dict) else {}

            # Merge order: defaults <- top-level strategy dict <- strategy-name dict
            merged = {}
            merged.update(getattr(self, 'freqai_model_parameters', {}) or {})
            # top-level freqai_model_parameters
            top_params = cfg.get('freqai_model_parameters')
            if isinstance(top_params, dict):
                merged.update(top_params)

            # strategy-level params
            strat_params = strat_cfg.get('freqai_model_parameters') if isinstance(strat_cfg.get('freqai_model_parameters'), dict) else None
            if isinstance(strat_params, dict):
                merged.update(strat_params)

            # named strategy override
            named_params = strat_name_cfg.get('freqai_model_parameters') if isinstance(strat_name_cfg.get('freqai_model_parameters'), dict) else None
            if isinstance(named_params, dict):
                merged.update(named_params)

            # Apply merged model params back to the strategy
            self.freqai_model_parameters = merged

            # Also allow enabling via strategy-specific flags
            strat_enabled = strat_name_cfg.get('freqai_enabled') if isinstance(strat_name_cfg.get('freqai_enabled'), bool) else None
            if strat_enabled is not None:
                self.freqai_enabled = strat_enabled

        # Log resolved settings for easier debugging (non-fatal)
        try:
            logger.info(f"freqai_enabled={self.freqai_enabled}")
            logger.info(f"freqai_model_parameters={self.freqai_model_parameters}")
        except Exception:
            # Best-effort logging; do not raise
            pass

    def informative_pairs(self):
        return [("BTC/USDT:USDT", "1h")]

    def fetch_sentiment(self) -> float:
        try:
            url = "https://api.coingecko.com/api/v3/coins/bitcoin?localization=false&tickers=false&market_data=false&community_data=true"
            response = requests.get(url, timeout=5)
            data = response.json()
            sentiment = data.get('sentiment_votes_up_percentage', 50) / 100
            logger.info(f"Fetched sentiment: {sentiment}")
            return sentiment
        except Exception as e:
            logger.warning(f"Sentiment fetch failed: {e}")
            return 0.5

    def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        dataframe['rsi'] = ta.RSI(dataframe, timeperiod=14)
        macd, macdsignal, macdhist = ta.MACD(dataframe)
        dataframe['macd'] = macd
        dataframe['macdsignal'] = macdsignal
        dataframe['macdhist'] = macdhist
        dataframe['sentiment'] = self.fetch_sentiment()

        # Debug: log the most recent calculated indicators for visibility during backtests
        try:
            last = dataframe.tail(1).iloc[0]
            logger.debug(f"Indicators (last row) - rsi={last.get('rsi')}, macdhist={last.get('macdhist')}, sentiment={last.get('sentiment')}")
        except Exception:
            pass

        if self.freqai_enabled and FreqaiDataKitchen is not None:
            dk = FreqaiDataKitchen(self.config)
            dataframe = dk.make_features(dataframe, self, metadata, self.freqai_feature_parameters)

        return dataframe

    def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        dataframe['enter_long'] = 0
        dataframe['enter_short'] = 0

        # Obtain predicted_delta series: prefer freqai predictions, otherwise use a simple momentum fallback
        use_fallback = True
        predicted_delta = None
        sentiment = dataframe['sentiment'] if 'sentiment' in dataframe.columns else 0.5

        if self.freqai_enabled and hasattr(self, 'freqai') and self.freqai is not None:
            try:
                pred_df = self.freqai.get_predictions(dataframe, metadata)
                if pred_df is not None and 'prediction' in pred_df:
                    predicted_delta = pred_df['prediction']
                    use_fallback = False
                else:
                    logger.warning("Missing or malformed prediction data; falling back to momentum predictor.")
            except Exception:
                logger.exception("Error obtaining freqai predictions; falling back to momentum predictor.")

        if use_fallback:
            # Momentum fallback: short-window percent change as a cheap proxy for predicted delta
            try:
                # 3-period return (can be tuned); clip to avoid extreme values
                predicted_delta = dataframe['close'].pct_change(periods=3).fillna(0).clip(-0.1, 0.1)
            except Exception:
                # Final safeguard: zero series if even that fails
                predicted_delta = pd.Series([0.0] * len(dataframe), index=dataframe.index)

        # Choose thresholds depending on whether we're using the ML predictor or fallback
        if use_fallback:
            pred_threshold_long = 0.001
            pred_threshold_short = -0.001
            # Relaxed fallback thresholds for easier signal generation during testing
            pred_threshold_long = 0.0005
            pred_threshold_short = -0.0005
            # For fallback, do not gate on sentiment (useful for offline testing)
            sentiment_high = 0.0
            sentiment_low = 1.0
            # Use a small MACD magnitude threshold: values with abs(macdhist) in the top 75%
            try:
                macd_abs_thresh = dataframe['macdhist'].abs().quantile(0.25)
            except Exception:
                macd_abs_thresh = 0.0
        else:
            pred_threshold_long = 0.005
            pred_threshold_short = -0.005
            sentiment_high = 0.6
            sentiment_low = 0.4

        # Now compute conditions; predicted_delta may be a Series or array-like
        try:
            pd_pred = pd.Series(predicted_delta, index=dataframe.index)
        except Exception:
            pd_pred = predicted_delta

        # When using fallback, use relaxed RSI bounds and require a meaningful macdhist magnitude
        if use_fallback:
            conditions_long = (
                (dataframe['rsi'] < 40) &
                (dataframe['macdhist'] > macd_abs_thresh) &
                (pd_pred > pred_threshold_long)
            )
        else:
            conditions_long = (
                (dataframe['rsi'] < 30) &
                (dataframe['macdhist'] > 0) &
                (pd_pred > pred_threshold_long) &
                (sentiment > sentiment_high)
            )
        dataframe.loc[conditions_long, 'enter_long'] = 1

        # Debug: log how many long conditions matched and a sample index
        try:
            n_long = int(conditions_long.sum())
            logger.debug(f"populate_entry_trend: {n_long} long conditions matched")
            if n_long > 0:
                sample_idxs = dataframe.loc[conditions_long].iloc[-3:].index.tolist()
                logger.debug(f"populate_entry_trend: sample long indices: {sample_idxs}")
        except Exception:
            pass

        if use_fallback:
            conditions_short = (
                (dataframe['rsi'] > 60) &
                (dataframe['macdhist'] < -macd_abs_thresh) &
                (pd_pred < pred_threshold_short)
            )
        else:
            conditions_short = (
                (dataframe['rsi'] > 70) &
                (dataframe['macdhist'] < 0) &
                (pd_pred < pred_threshold_short) &
                (sentiment < sentiment_low)
            )

        dataframe.loc[conditions_short, 'enter_short'] = 1

        # Debug: log how many short conditions matched
        try:
            n_short = int(conditions_short.sum())
            logger.debug(f"populate_entry_trend: {n_short} short conditions matched")
            if n_short > 0:
                sample_idxs = dataframe.loc[conditions_short].iloc[-3:].index.tolist()
                logger.debug(f"populate_entry_trend: sample short indices: {sample_idxs}")
        except Exception:
            pass

        return dataframe

    def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        dataframe['exit_long'] = 0
        dataframe['exit_short'] = 0

        exit_long_mask = (dataframe['rsi'] > 70) | (dataframe['sentiment'] < 0.4)
        exit_short_mask = (dataframe['rsi'] < 30) | (dataframe['sentiment'] > 0.6)
        dataframe.loc[exit_long_mask, 'exit_long'] = 1
        dataframe.loc[exit_short_mask, 'exit_short'] = 1

        try:
            n_exit_long = int(exit_long_mask.sum())
            n_exit_short = int(exit_short_mask.sum())
            logger.debug(f"populate_exit_trend: exit_long={n_exit_long}, exit_short={n_exit_short}")
        except Exception:
            pass

        return dataframe

    def leverage(self, pair: str, current_time: datetime, current_rate: float,
                 proposed_leverage: float, max_leverage: float, entry_tag: str, side: str,
                 **kwargs) -> float:
        """
        Compute dynamic leverage. Returns a numeric leverage value.
        Note: use `default_leverage` as the numeric default to avoid recursion.
        """
        if self.freqai_enabled and hasattr(self, 'freqai') and self.freqai is not None:
            pred_df = self.freqai.get_predictions(pd.DataFrame(), {'pair': pair})

            if pred_df is None or 'prediction' not in pred_df:
                return self.default_leverage

            vol_pred = np.std(pred_df['prediction'][-10:])
            sentiment = self.fetch_sentiment()
            leverage_adj = self.default_leverage * (1 - vol_pred * 5) * (1 + (sentiment - 0.5))
            # Ensure leverage is at least 1 and does not exceed the strategy default
            leverage_final = min(max(leverage_adj, 1), self.default_leverage)
            logger.info(f"Adjusted leverage: {leverage_final}")
            return leverage_final
        return self.default_leverage

    from typing import Any

    def custom_stoploss(self, pair: str, trade: Any, current_time: datetime, current_rate: float,
                        current_profit: float, **kwargs) -> float:
        """
        Custom stoploss: once trade profit reaches 1% (0.01), start trailing the stoploss
        so that it remains 1% behind the current profit. Return a stoploss as a
        negative fraction (e.g., -0.02 means 2% stoploss). Return 1 to keep existing
        stoploss behavior.
        """
        try:
            # current_profit is a fraction (e.g., 0.02 for +2%) provided by freqtrade
            profit = float(current_profit)
        except Exception:
            return 1

        # If profit hasn't reached the trailing threshold, use normal strategy stoploss
        trailing_threshold = 0.01  # 1%
        trailing_distance = 0.01   # keep stoploss 1% behind current profit

        if profit >= trailing_threshold:
            # Desired stoploss level: current_profit - trailing_distance
            new_stop = profit - trailing_distance
            # Convert to negative stoploss fraction (freqtrade expects negative)
            stoploss_frac = -abs(new_stop)
            # Ensure we don't set a stoploss that's higher (less protective) than strategy stoploss
            strategy_stop = getattr(self, 'stoploss', -0.05)
            # Use the tighter (closer to zero) of the two (i.e., max since both negative)
            final_stop = max(stoploss_frac, strategy_stop)
            logger.debug(f"custom_stoploss: profit={profit:.4f} new_stop={final_stop:.4f}")
            return float(final_stop)

        return 1

    def custom_entry(self, pair: str, current_time: datetime, current_rate: float,
                     current_profit: float, **kwargs):
        """
        Implements grid order placement logic based on prediction and sentiment.
        """
        # If freqai is disabled or not available, do not attempt custom grid entry
        if not self.freqai_enabled or not hasattr(self, 'freqai') or self.freqai is None:
            return None

        pred_df = self.freqai.get_predictions(pd.DataFrame(), {'pair': pair})
        if pred_df is None or 'prediction' not in pred_df:
            logger.warning("No prediction data for custom_entry.")
            return None

        sentiment = self.fetch_sentiment()
        predicted_delta = pred_df['prediction'].iloc[-1]
        vol_pred = np.std(pred_df['prediction'][-10:])
        dynamic_spacing = self.grid_spacing * (1 + vol_pred + (sentiment - 0.5))

        grid_orders = []
        direction = 'buy' if predicted_delta > 0 else 'sell'
        base_price = current_rate

        for i in range(1, self.grid_levels + 1):
            offset = base_price * dynamic_spacing * i
            price = base_price - offset if direction == 'buy' else base_price + offset
            grid_orders.append({
                'price': round(price, 2),
                'amount': 1 / self.grid_levels,
                'side': direction,
                'leverage': self.leverage(pair, current_time, current_rate, self.default_leverage, self.default_leverage, 'grid', direction)
            })

        logger.info(f"Placing {direction} grid orders: {grid_orders}")
        return grid_orders
