# source: https://raw.githubusercontent.com/IvPalmer/Master-Trader/978b204f9571729b05462b2b487f594226d77478/ft_userdata/user_data/strategies/OITrendPullbackV1.py
# directory_url: https://github.com/IvPalmer/Master-Trader/blob/main/ft_userdata/user_data/strategies/
# User: IvPalmer
# Repository: Master-Trader
# --------------------"""Github_IvPalmer_Master_Trader__OITrendPullbackV1__20260922_204015 — spot trend continuation confirmed by futures OI.

The bot buys a liquid alt only after a pullback reclaims the 20h EMA inside a
larger 50/200h uptrend and Binance USDT-M open interest has expanded by at
least 2% over roughly one hour.  OI is public market data; failures fail
closed and never block exit management.
"""

from __future__ import annotations

import json
import logging
import time
import urllib.parse
import urllib.request
from concurrent.futures import Future, ThreadPoolExecutor
from datetime import datetime

import numpy as np
import pandas as pd
import talib.abstract as ta
from freqtrade.strategy import IStrategy, informative
from pandas import DataFrame

logger = logging.getLogger(__name__)


class Github_IvPalmer_Master_Trader__OITrendPullbackV1__20260922_204015(IStrategy):
    INTERFACE_VERSION = 3

    timeframe = "1h"
    process_only_new_candles = True
    startup_candle_count = 240

    minimal_roi = {"0": 0.06, "360": 0.04, "720": 0.025, "1440": 0.01}
    stoploss = -0.05
    trailing_stop = True
    trailing_stop_positive = 0.025
    trailing_stop_positive_offset = 0.04
    trailing_only_offset_is_reached = True
    use_exit_signal = True
    exit_profit_only = False

    # 0.0 means "open interest is not CONTRACTING" while price reclaims the
    # EMA20 inside a 50/200 uptrend — participation holding through the
    # pullback, which is the continuation thesis this strategy is named for.
    #
    # It replaced 0.02 because that threshold sat ABOVE the 99th percentile of
    # the real 45-minute OI-growth distribution (p50 +0.01%, p90 +0.39%, p99
    # +1.46% over 30 days x 8 pairs). It was not a filter, it was an off
    # switch: the TA conjunction produced 76 setups in that window and the gate
    # admitted zero, matching the live record of no trade since 2026-08-23.
    # That distribution finding stands on its own and is why reverting to 0.02
    # would knowingly restore the off switch.
    #
    # The accompanying PROFITABILITY claim was WITHDRAWN the same day — the
    # calibration did not reproduce the deployed contract (wrong OI window,
    # max_open_trades and custom_exit unmodelled, perp klines for a spot bot).
    # Corrected it shows a loss, and the gate cannot beat a placebo mask of
    # equal pass rate. So "not an off switch" is NOT "profitable", and the bot
    # runs DRY until a valid calibration exists.
    # See preregistration oi-gate-recalibration-2026-08-30.
    oi_min_growth = 0.0
    oi_sample_interval_s = 300
    oi_lookback_s = 45 * 60
    oi_max_age_s = 15 * 60
    _oi_history: dict[str, list[tuple[float, float]]] = {}
    _oi_growth: dict[str, float] = {}
    _oi_growth_updated: dict[str, float] = {}
    _last_oi_poll = 0.0
    # Entry-funnel diagnostics. Per-pair throttle so a 1h strategy logs the
    # funnel roughly hourly rather than on every candle-processing call.
    FUNNEL_LOG_INTERVAL_S = 3600
    FUNNEL_WINDOW_CANDLES = 500
    _funnel_last_log: dict[str, float] = {}
    _oi_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="oi-poll")
    _oi_futures: dict[str, Future] = {}

    @property
    def protections(self):
        return [
            {"method": "CooldownPeriod", "stop_duration_candles": 3},
            {
                "method": "StoplossGuard",
                "lookback_period_candles": 72,
                "trade_limit": 2,
                "stop_duration_candles": 24,
                "only_per_pair": False,
            },
        ]

    @informative("1h", "BTC/{stake}")
    def populate_indicators_btc_1h(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50)
        dataframe["ema200"] = ta.EMA(dataframe, timeperiod=200)
        return dataframe

    def bot_loop_start(self, current_time: datetime, **kwargs) -> None:
        """Collect OI asynchronously and expire failed/stale confirmations."""
        if not self._uses_live_oi():
            return
        now = time.time()

        # Never block Freqtrade's strategy loop on public HTTP. A completed
        # batch is harvested here; the next batch is submitted below.
        if self._oi_futures:
            if not all(future.done() for future in self._oi_futures.values()):
                return
            results = []
            for pair, future in self._oi_futures.items():
                try:
                    _, oi, observed_at = future.result()
                except Exception as exc:
                    logger.warning("OI future failed for %s: %s", pair, exc)
                    oi = None
                    observed_at = None
                results.append((pair, oi, observed_at))
            self._oi_futures = {}
            for pair, oi, observed_at in results:
                self._record_oi_result(pair, oi, now, observed_at)
            valid = sum(oi is not None for _, oi, _ in results)
            ready = sum(self._fresh_oi_growth(pair, now) is not None for pair, _, _ in results)
            logger.info(
                "OI snapshot: valid=%d/%d growth_ready=%d",
                valid, len(results), ready,
            )

        if now - self._last_oi_poll < self.oi_sample_interval_s:
            return
        self._last_oi_poll = now
        pairs = self.dp.current_whitelist() if self.dp else []
        self._oi_futures = {
            pair: self._oi_executor.submit(self._fetch_oi, pair) for pair in pairs
        }

    def _uses_live_oi(self) -> bool:
        mode = self.config.get("runmode")
        return getattr(mode, "value", mode) in ("live", "dry_run")

    def _record_oi_result(
        self, pair: str, oi: float | None, now: float, observed_at: float | None,
    ) -> None:
        """Use exchange observation times for both endpoints and freshness."""
        if (
            oi is None or not np.isfinite(oi) or oi <= 0
            or observed_at is None or not np.isfinite(observed_at)
            or not 0 <= now - observed_at <= self.oi_max_age_s
        ):
            self._oi_growth.pop(pair, None)
            self._oi_growth_updated.pop(pair, None)
            return
        history = self._oi_history.setdefault(pair, [])
        # A delayed or repeated response must not manufacture a new endpoint.
        if history and observed_at <= history[-1][0]:
            self._fresh_oi_growth(pair, now)
            return
        history.append((observed_at, oi))
        cutoff = observed_at - 2 * 3600
        history[:] = [(ts, value) for ts, value in history if ts >= cutoff]
        baseline_end = observed_at - self.oi_lookback_s
        eligible = [
            (ts, value) for ts, value in history
            if baseline_end - self.oi_sample_interval_s <= ts <= baseline_end
        ]
        # At most one poll interval before the 45m endpoint. An outage must
        # not turn a two-hour change into a seemingly fresh 45m confirmation.
        baseline = max(eligible, key=lambda item: item[0])[1] if eligible else None
        if baseline and baseline > 0:
            self._oi_growth[pair] = oi / baseline - 1.0
            self._oi_growth_updated[pair] = observed_at
        else:
            self._oi_growth.pop(pair, None)
            self._oi_growth_updated.pop(pair, None)

    def _fresh_oi_growth(self, pair: str, now: float | None = None) -> float | None:
        now = time.time() if now is None else now
        updated = self._oi_growth_updated.get(pair)
        if updated is None or not 0 <= now - updated <= self.oi_max_age_s:
            self._oi_growth.pop(pair, None)
            self._oi_growth_updated.pop(pair, None)
            return None
        return self._oi_growth.get(pair)

    @staticmethod
    def _fetch_oi(pair: str) -> tuple[str, float | None, float | None]:
        symbol = pair.split("/")[0].replace("1000", "1000") + "USDT"
        url = "https://fapi.binance.com/fapi/v1/openInterest?" + urllib.parse.urlencode(
            {"symbol": symbol}
        )
        try:
            req = urllib.request.Request(url, headers={"User-Agent": "master-trader/1"})
            with urllib.request.urlopen(req, timeout=2.0) as response:
                payload = json.load(response)
            value = float(payload["openInterest"])
            # Binance's `time` is the OI observation time in milliseconds,
            # not the time this asynchronous result is harvested by the bot.
            observed_at = float(payload["time"]) / 1000.0
            return pair, value, observed_at
        except Exception as exc:
            logger.warning("OI fetch failed for %s: %s", pair, exc)
            return pair, None, None

    def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        dataframe["ema20"] = ta.EMA(dataframe, timeperiod=20)
        dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50)
        dataframe["ema200"] = ta.EMA(dataframe, timeperiod=200)
        dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14)
        dataframe["vol_sma"] = dataframe["volume"].rolling(20).mean()
        dataframe["oi_growth"] = np.nan
        dataframe["oi_min_growth"] = float(self.oi_min_growth)
        # The live observation confirms only the current decision. Copying it
        # onto old candles fabricates a historical OI series. Backtests need
        # their own causal OI history and must not consult the live endpoint.
        if self._uses_live_oi() and not dataframe.empty:
            fresh_growth = self._fresh_oi_growth(metadata["pair"])
            if fresh_growth is not None:
                dataframe.iloc[-1, dataframe.columns.get_loc("oi_growth")] = fresh_growth
        dataframe["btc_trend"] = (
            (dataframe["btc_usdt_close_1h"] > dataframe["btc_usdt_ema200_1h"])
            & (dataframe["btc_usdt_ema50_1h"] > dataframe["btc_usdt_ema200_1h"])
        ).astype(int)
        return dataframe

    def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        # Named terms so the funnel below can attribute a rejection. The
        # conjunction is byte-for-byte the same set of conditions as before —
        # this is instrumentation, not a signal change.
        terms = {
            "uptrend_50_200": dataframe["ema50"] > dataframe["ema200"],
            "above_ema200": dataframe["close"] > dataframe["ema200"],
            "above_ema20": dataframe["close"] > dataframe["ema20"],
            "reclaim_ema20": dataframe["close"].shift(1) <= dataframe["ema20"].shift(1),
            "near_ema20_2pct": dataframe["close"] <= dataframe["ema20"] * 1.02,
            "rsi_45_68": dataframe["rsi"].between(45, 68),
            "volume_1_10x": dataframe["volume"] > dataframe["vol_sma"] * 1.10,
            "oi_growth_2pct": dataframe["oi_growth"] >= self.oi_min_growth,
            "btc_trend": dataframe["btc_trend"] == 1,
            "volume_positive": dataframe["volume"] > 0,
        }

        signal = None
        for condition in terms.values():
            signal = condition if signal is None else (signal & condition)
        dataframe.loc[signal, "enter_long"] = 1

        self._log_entry_funnel(dataframe, metadata, terms, signal)
        return dataframe

    def _log_entry_funnel(self, dataframe: DataFrame, metadata: dict,
                          terms: dict, signal) -> None:
        """Report which condition is actually blocking entries.

        Zero trades can mean "the market never set up" or "this gate can never
        be satisfied", and the two demand opposite responses. Without
        attribution they are indistinguishable, which is exactly the position
        this strategy was in: live since 2026-08-23 with no trade and no way
        to tell which. The OI term is the suspect one — it is populated only
        by a live HTTP poll, so it is NaN in every backtest and the whole
        conjunction is silently False.

        Emitted once per pair per FUNNEL_LOG_INTERVAL_S, at INFO. Cheap:
        boolean sums over an in-memory frame already built above.
        """
        pair = metadata.get("pair", "?")
        now = time.time()
        if now - self._funnel_last_log.get(pair, 0.0) < self.FUNNEL_LOG_INTERVAL_S:
            return
        self._funnel_last_log[pair] = now

        window = min(len(dataframe), self.FUNNEL_WINDOW_CANDLES)
        if window == 0:
            return
        passes = {name: int(term.tail(window).fillna(False).sum())
                  for name, term in terms.items()}
        # OI is attached only to the latest decision, so its count is not a
        # historical frequency like the TA terms. Report its current value.
        current_oi = self._fresh_oi_growth(pair)
        historical_passes = {name: count for name, count in passes.items()
                             if name != "oi_growth_2pct"}
        scarcest = min(historical_passes, key=historical_passes.get)
        if current_oi is None or current_oi < self.oi_min_growth:
            scarcest = "oi_growth_2pct"
        if current_oi is None:
            oi_note = ("oi_growth=UNAVAILABLE (no fresh reading; a restart "
                       "clears the 45m baseline and blocks entries until it "
                       "refills)")
        else:
            oi_note = (f"oi_growth={current_oi * 100:+.2f}% vs threshold "
                       f"{self.oi_min_growth * 100:.2f}% "
                       f"(gap {(self.oi_min_growth - current_oi) * 100:+.2f}pp)")

        logger.info(
            "OI entry funnel %s (last %d candles): %s | entries=%d | "
            "binding constraint=%s (%d/%d) | %s",
            pair, window,
            " ".join(f"{k}={v}" for k, v in passes.items()),
            int(signal.tail(window).fillna(False).sum()),
            scarcest, passes[scarcest], window, oi_note,
        )

    def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        # Keep dataframe exits empty. Freqtrade rejects an entry whenever the
        # same candle also has exit_long=1. A valid EMA20 reclaim can happen
        # below EMA50, so the EMA50 risk exit belongs in custom_exit(), where
        # it applies only to positions that are already open.
        dataframe["exit_long"] = 0
        return dataframe

    def custom_exit(
        self,
        pair: str,
        trade,
        current_time: datetime,
        current_rate: float,
        current_profit: float,
        **kwargs,
    ) -> str | None:
        """Exit an existing long after a candle closes below EMA50.

        OI is deliberately absent from this path: stale public OI blocks new
        entries but must never disable management of an open position.
        """
        if not self.dp:
            return None
        dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
        if dataframe is None or dataframe.empty:
            return None
        candle = dataframe.iloc[-1]
        candle_date = candle.get("date")
        trade_open = getattr(trade, "open_date_utc", None)
        if candle_date is None or trade_open is None:
            return None
        try:
            candle_ts = pd.Timestamp(candle_date)
            trade_open_ts = pd.Timestamp(trade_open)
            if pd.isna(candle_ts) or pd.isna(trade_open_ts):
                return None
            candle_ts = (
                candle_ts.tz_localize("UTC")
                if candle_ts.tzinfo is None
                else candle_ts.tz_convert("UTC")
            )
            trade_open_ts = (
                trade_open_ts.tz_localize("UTC")
                if trade_open_ts.tzinfo is None
                else trade_open_ts.tz_convert("UTC")
            )
        except (TypeError, ValueError):
            return None
        # OHLCV `date` is the candle's opening time. Requiring it to be later
        # than the trade open guarantees a complete post-entry candle before
        # the EMA50 exit can fire, avoiding enter-at-open/exit-seconds-later
        # churn when the valid EMA20 reclaim signal is still below EMA50.
        if candle_ts <= trade_open_ts:
            return None
        close = candle.get("close")
        ema50 = candle.get("ema50")
        if close is None or ema50 is None:
            return None
        if np.isfinite(close) and np.isfinite(ema50) and float(close) < float(ema50):
            return "ema50_break"
        return None
