# source: https://raw.githubusercontent.com/nateemma/strategies/c7e08ec20ca021ead1ce22104e0c6285fef9fe67/Reversion/OversoldReversion.py
# directory_url: https://github.com/nateemma/strategies/blob/master/Reversion/
# User: nateemma
# Repository: strategies
# --------------------"""Github_nateemma_strategies__OversoldReversion__20260825_041258 — absolute-oversold mean reversion on liquid alts (1h).

The deliberate counterpart to Basket/MomentumRegimeBasket15m: that book buys what has
gone UP and holds the winners; this one buys deep dislocations and holds a fixed window.
They are run as INDEPENDENT strategies, not blended.

*** SIGNAL (validated, see Reversion/README.md) ***
Enter when RSI(14) crosses BELOW 30 *and* price is more than 30% below its 50-day SMA.
The dislocation term does the work: bucketing by distance-below-SMA50 gives a median
fwd-48h of +136bp in the deepest quintile vs +46/+24/+21/0 for the rest, while RSI depth
barely separates (+44 vs +27). Deep-oversold-and-far-from-trend, not merely oversold.

The -20% threshold used in an earlier draft LOSES MONEY net of costs (-5.9% CAGR): per
signal medians are not portfolio P&L, because you take EVERY signal and pay costs on all
of them. -30% is the viable line.

*** HOLD ***
The signal DECAYS SLOWLY -- mean fwd return rises 4h +64bp -> 48h +105 -> 72h +126 -> 96h
+109. That slow decay is the whole reason this is tradeable where the earlier
CROSS-SECTIONAL reversion study was not: low turnover tolerates costs. A fixed 72h hold
matches the peak. (studies/study1_xsec was rejected -- ZEC was ~92% of its edge and
liquid-only lost -300% at 10bp. THIS signal is liquidity-ROBUST: liquid-only is as good
or better than the full universe, +138.7 vs +125.9bp at 72h.)

*** SIZING -- MANY SMALL BETS ***
MAX_POSITIONS=12, unlike the momentum book's TOP_N=3. This is a real lever, monotonic in
the sweep: Sharpe 0.41/0.46/0.54/0.66 for 3/5/8/12, vol 36%->18%, maxDD -57%->-29%.

*** VECTORISED EXPECTATION (net 40bp, ex-ONE, 14 pairs) ***
  pooled 2021-2026  +11.0% CAGR, 18% vol, Sharpe 0.66, maxDD -29%, 539 trades
  P1 +19.7% (Sh 0.72) | P2 +2.3% (Sh 1.07) | P3 +13.6% (Sh 1.30)
A real backtest should come in BELOW that -- the sim has no slippage and assumes fills at
the modelled prices.

*** NO BTC REGIME GATE -- deliberate. *** btc_corr found a BTC-uptrend entry gate
over-filters mean reversion by ~90% because it fights buy-low. The momentum book's
regime gate is load-bearing; here it would be actively wrong.

Liquidity handling is ported from MomentumRegimeBasket15m, where uncapped exits proved to
carry 75% of reported profit. Entries AND exits are capped to a share of a candle's quote
volume. Duplicated rather than shared because that class's helpers are entangled with its
cross-sectional membership logic; extracting a mixin is future work.

Config: config/config_reversion_1h.json (max_open_trades == MAX_POSITIONS, stake
"unlimited", liquid-only whitelist -- do NOT reuse the momentum whitelist).
"""
from __future__ import annotations

import talib.abstract as ta
from pandas import DataFrame

from freqtrade.persistence import Trade
from freqtrade.strategy import IStrategy


class Github_nateemma_strategies__OversoldReversion__20260825_041258(IStrategy):
    timeframe = "1h"
    can_short = False
    process_only_new_candles = True
    startup_candle_count = 1250        # 50d SMA on 1h = 1200 bars, + RSI warmup
    stoploss = -0.99                   # exit is time-based, not stop-based
    minimal_roi = {"0": 100}           # ROI off
    trailing_stop = False
    use_exit_signal = True             # REQUIRED: freqtrade gates custom_exit on this
                                       # (interface.py: `if self.use_exit_signal:` wraps the
                                       # custom_exit call). Setting it False silently disables
                                       # the time-based exit -- trades then ran 184 days.
    use_custom_stoploss = False
    position_adjustment_enable = True  # liquidity-capped accumulation and unwind

    RSI_PERIOD = 14
    RSI_THRESHOLD = 30.0
    SMA_HOURS = 24 * 50                # 50-day SMA on hourly bars
    DISLOCATION = -0.30                # price must be >30% below that SMA
    HOLD_HOURS = 72
    MAX_POSITIONS = 12                 # == config max_open_trades

    # liquidity discipline (same as the momentum book)
    MIN_QUOTE_VOLUME = 1000
    QUOTE_VOLUME_HEADROOM_MULT = 10.0
    FILL_VOLUME_LAG = 0                # iloc[-1] is the last COMPLETED candle in backtest
    EXIT_LIQUIDITY_CAP = True

    def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        dataframe["rsi"] = ta.RSI(dataframe, timeperiod=self.RSI_PERIOD)
        sma = dataframe["close"].rolling(self.SMA_HOURS).mean()
        dataframe["dislocation"] = dataframe["close"] / sma - 1
        dataframe["rsi_cross_down"] = (
            (dataframe["rsi"] < self.RSI_THRESHOLD)
            & (dataframe["rsi"].shift(1) >= self.RSI_THRESHOLD)
        )
        return dataframe

    def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        dataframe.loc[
            dataframe["rsi_cross_down"]
            & (dataframe["dislocation"] < self.DISLOCATION)
            & (dataframe["volume"] > 0),
            "enter_long",
        ] = 1
        return dataframe

    def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        return dataframe          # time-based exit lives in custom_exit

    def custom_exit(self, pair: str, trade: Trade, current_time, current_rate,
                    current_profit: float, **kwargs):
        held_h = (current_time - trade.open_date_utc).total_seconds() / 3600.0
        if held_h >= self.HOLD_HOURS:
            return "hold_expired"
        return None

    # ---- liquidity helpers (ported from MomentumRegimeBasket15m) ----
    def _quote_volume(self, df) -> float:
        i = -1 - self.FILL_VOLUME_LAG
        if df is None or len(df) < abs(i):
            return 0.0
        bar = df.iloc[i].squeeze()
        return float(bar["volume"]) * float(bar["close"])

    def _portfolio_value(self) -> float:
        pv = self.wallets.get_free(self.config["stake_currency"])
        for ot in Trade.get_trades_proxy(is_open=True):
            df, _ = self.dp.get_analyzed_dataframe(ot.pair, self.timeframe)
            price = df["close"].iloc[-1] if df is not None and len(df) else ot.open_rate
            pv += ot.amount * price
        return pv

    def custom_stake_amount(self, pair, current_time, current_rate, proposed_stake,
                            min_stake, max_stake, leverage, entry_tag, side, **kwargs):
        if self.dp.runmode.value in ("plot", "other"):
            return proposed_stake
        df, _ = self.dp.get_analyzed_dataframe(pair=pair, timeframe=self.timeframe)
        fillable = self._quote_volume(df) / self.QUOTE_VOLUME_HEADROOM_MULT
        target = self._portfolio_value() / self.MAX_POSITIONS
        stake = min(proposed_stake, target, fillable, max_stake)
        if min_stake and stake < min_stake:
            return 0.0
        return max(stake, 0.0)

    def confirm_trade_entry(self, pair, order_type, amount, rate, time_in_force,
                            current_time, entry_tag, side, **kwargs):
        if self.dp.runmode.value in ("plot", "other"):
            return True
        df, _ = self.dp.get_analyzed_dataframe(pair=pair, timeframe=self.timeframe)
        return self._quote_volume(df) >= self.MIN_QUOTE_VOLUME

    def confirm_trade_exit(self, pair, trade, order_type, amount, rate, time_in_force,
                           exit_reason, current_time, **kwargs):
        if not self.EXIT_LIQUIDITY_CAP or self.dp.runmode.value in ("plot", "other"):
            return True
        if exit_reason in ("force_exit", "stop_loss", "liquidation"):
            return True
        df, _ = self.dp.get_analyzed_dataframe(pair=pair, timeframe=self.timeframe)
        sellable = self._quote_volume(df) / self.QUOTE_VOLUME_HEADROOM_MULT
        return (amount * rate) <= sellable

    def adjust_trade_position(self, trade, current_time, current_rate, current_profit,
                              min_stake, max_stake, current_entry_rate, current_exit_rate,
                              current_entry_profit, current_exit_profit, **kwargs):
        if self.dp.runmode.value in ("plot", "other"):
            return None
        df, _ = self.dp.get_analyzed_dataframe(pair=trade.pair, timeframe=self.timeframe)
        if df is None or len(df) == 0:
            return None
        current_value = trade.amount * current_rate
        held_h = (current_time - trade.open_date_utc).total_seconds() / 3600.0
        fillable = self._quote_volume(df) / self.QUOTE_VOLUME_HEADROOM_MULT

        if held_h >= self.HOLD_HOURS:
            # unwinding: release only what this candle can absorb; when the remainder
            # fits, confirm_trade_exit lets custom_exit finish it
            if not self.EXIT_LIQUIDITY_CAP or fillable <= 0 or fillable >= current_value:
                return None
            reduce = -min(fillable, current_value)
            if min_stake and abs(reduce) < min_stake:
                return None
            return reduce

        target = self._portfolio_value() / self.MAX_POSITIONS
        if current_value >= target * 0.98:
            return None
        add = min(target - current_value, fillable, max_stake)
        if add <= 0 or (min_stake and add < min_stake):
            return None
        return add
