# source: https://raw.githubusercontent.com/nateemma/strategies/a8bff4cce2d4e4b02904c0764f05188ee37b60ae/Basket/BasketStrategy.py
"""
Github_nateemma_strategies__BasketStrategy__20260710_214739 — shared base for slow, portfolio-level basket rebalancing.

This is NOT a fast trading scheme. It holds a fixed basket of coins (the
whitelist) plus a stablecoin cash bucket, and nudges the basket back toward
target weights infrequently and deliberately. Entries/exits are portfolio
maintenance, not signals.

Design notes / framework divergence
-----------------------------------
This inherits Freqtrade's ``IStrategy`` DIRECTLY, not the repo's ML
``BaseStrategy``. BaseStrategy is built for fast ML trading: trailing stop,
ROI, custom_stoploss and custom_exit are all tuned to *close* positions. A
basket is meant to be *held and maintained*, so we start from a clean
interface and neutralise every auto-exit path instead of fighting inherited
ones. All the exit machinery below is deliberately disabled.

Freqtrade version: targets 2026.4-dev (INTERFACE_VERSION 3). Callback
signatures used: custom_stake_amount, adjust_trade_position (negative stake
== partial trim), bot_loop_start.

No-lookahead discipline
-----------------------
Every indicator here is causal:
  * The Bollinger mid-band is an SMA over a trailing window (qtpylib /
    talib), so row t only uses rows <= t.
  * In callbacks we read ``dp.get_analyzed_dataframe`` and take ``.iloc[-1]``
    — in backtest Freqtrade truncates that frame to the current candle, so
    ``.iloc[-1]`` is the latest CLOSED bar, never a future one.
  * Portfolio value comes from ``self.wallets`` (present state only).
Spots that need care are flagged inline with ``# LOOKAHEAD:``.
"""

from __future__ import annotations

import atexit
import logging
from datetime import datetime, timedelta

from pandas import DataFrame
import freqtrade.vendor.qtpylib.indicators as qtpylib
from freqtrade.strategy import (
    IStrategy,
    BooleanParameter,
    DecimalParameter,
    IntParameter,
)

log = logging.getLogger(__name__)


class Github_nateemma_strategies__BasketStrategy__20260710_214739(IStrategy):
    INTERFACE_VERSION = 3

    # 4h suits a coarse-timing, slow rebalancing scheme: the cadence gate
    # throttles actual trades to ~daily, and adjust_trade_position is called
    # once per open trade PER CANDLE, so a finer timeframe (e.g. 15m) just
    # multiplies Freqtrade's per-call Trade deepcopy overhead for no benefit.
    timeframe = "4h"
    can_short = False
    process_only_new_candles = True

    # ---- exits neutralised: the basket is HELD and maintained ----------
    # ROI never triggers (10 000%). Stoploss effectively disabled so a coin
    # we intend to keep rebalancing is never fully closed by a stop. Trailing
    # off. Exit signals off — positions leave the basket only via a rebalance
    # trim in adjust_trade_position(), never via an exit signal / ROI / stop.
    minimal_roi = {"0": 100.0}
    stoploss = -0.99
    trailing_stop = False
    use_exit_signal = False
    use_custom_stoploss = False

    # Rebalancing is done by scaling positions, not by opening new trades.
    position_adjustment_enable = True

    # Must exceed bb_period. Raise this if you raise bb_period.
    startup_candle_count = 60

    # =====================================================================
    # TUNABLE PARAMETERS  (hyperopt-optimisable; slow-scheme defaults)
    #
    # All in the 'buy' space, so `hyperopt --spaces buy` optimises the whole
    # scheme in one pass. Optimised values are written to <Strategy>.json and
    # override these defaults. Non-hyperopt backtests use the defaults below.
    #
    # CAVEAT: bb_period / bb_std are used in populate_indicators(), which
    # Freqtrade computes ONCE unless you pass `--analyze-per-epoch`. Without
    # that flag they stay at their defaults during hyperopt; the callback
    # params (band, cadence, weights, gate, CPPI, runaway) vary every epoch.
    # =====================================================================

    # --- Basket weights -------------------------------------------------
    # Fraction of total portfolio held as undeployed stablecoin cash. The
    # cash bucket is NOT a position — it's simply stake we never deploy.
    # NOTE: CPPI overrides weight logic entirely and treats cash as a
    # DYNAMIC residual, so this only governs the constant-mix path.
    cash_target_weight = DecimalParameter(0.0, 0.5, default=0.20, decimals=2, space="buy")

    # Per-coin target weight OVERRIDE. Left as a plain attr (not hyperopt):
    # None => split (1 - cash) equally across the whitelist. Set explicitly to
    # weight coins unequally.
    target_weight_per_coin: float | None = None

    # Hard per-position concentration cap (plain attr, not hyperopt). None =>
    # off. Set e.g. 0.20 so no single coin may exceed 20% of portfolio value:
    # a runaway winner is trimmed back to the cap IMMEDIATELY, bypassing the
    # rebalance cadence and BB timing gate. Bounds single-coin tail risk when a
    # coin runs hard between deliberate rebalances (the normal band-trim can lag).
    max_position_weight: float | None = None

    # --- Bollinger timing gate -----------------------------------------
    # The mid-BB is an EXECUTION-TIMING filter, not a second signal. The
    # drift band is the primary trigger; the BB only decides whether a
    # triggered trade is allowed to fire right now:
    #   * add / initial entry  → only when price is BELOW mid-BB
    #   * trim                 → only when price is ABOVE mid-BB
    # Only the MID band is used (an SMA of typical price), so there is NO
    # std-dev parameter — band width would have no effect on this strategy.
    # (bb_period is an indicator param — see --analyze-per-epoch caveat above)
    bb_period = IntParameter(10, 50, default=20, space="buy")

    # Master switch for the BB timing gate. True = gated (deliberate timing).
    # False = "raw band-rebalance" arm (entries fire ASAP, rebalances ignore BB).
    use_bb_gate = BooleanParameter(default=True, space="buy")

    # How far BELOW mid-BB price must be to count as a "dip" for BUYING (both
    # initial entry and rebalance top-ups). 0.0 (default) = plain `close < mid`,
    # which is loose (true ~half the time). e.g. 0.02 requires price 2% under
    # mid-BB — a more selective entry/add, at the cost of filling more slowly.
    # Sell-side (trim) gating is unaffected.
    entry_band = DecimalParameter(0.0, 0.10, default=0.0, decimals=3, space="buy")

    # --- Band rebalancing ----------------------------------------------
    # Only act when a coin's weight drifts more than this from target.
    # Wide by default so we trade rarely.
    rebalance_band = DecimalParameter(0.01, 0.15, default=0.05, decimals=2, space="buy")

    # --- Cadence --------------------------------------------------------
    # Check rebalances at most this often (per pair), in hours. Not every candle.
    rebalance_interval_hours = IntParameter(6, 168, default=24, space="buy")

    # --- Profit-skim (INCOME overlay) -----------------------------------
    # When ON, a fraction of each new equity high is moved to a RESERVED cash
    # bucket that is NEVER redeployed ("taking chips off the table"). In LIVE
    # you would periodically withdraw that reserved amount as income; in
    # backtest freqtrade can't withdraw mid-run, so it sits as idle cash —
    # which lowers exposure and drawdown but also caps upside (banked money
    # doesn't compound, and earns 0). This is a decumulation/income overlay,
    # NOT alpha. Off by default (existing variants behave exactly as before).
    profit_skim_enable = BooleanParameter(default=False, space="buy")
    skim_fraction = DecimalParameter(0.0, 0.8, default=0.25, decimals=2, space="buy")

    # =====================================================================

    # per-pair timestamp of the last rebalance check that acted
    _last_rebalance: dict[str, datetime]

    def bot_loop_start(self, current_time: datetime, **kwargs) -> None:
        # lazily init per-instance state (avoids class-level mutable default)
        if not hasattr(self, "_last_rebalance"):
            self._last_rebalance = {}

        # NAV tracking for the exit summary — for ALL basket strategies, not
        # just skim. A held book is evaluated on its mark-to-market NAV and its
        # cash/deployed split, not on (never-intended) realized trades. Snapshot
        # once per candle: final value, cash bucket, and average cash weight.
        if not hasattr(self, "_nav_count"):
            self._nav_count = 0
            self._nav_cash_sum = 0.0
            self._nav_last_pv = 0.0
            self._nav_last_cash = 0.0
            self._nav_start = float(self.config.get("dry_run_wallet") or 0.0)
            atexit.register(self._log_basket_summary)
        pv = self._portfolio_value()
        cash = self.wallets.get_free(self.config["stake_currency"])
        self._nav_last_pv = pv
        self._nav_last_cash = cash
        if pv > 0:
            self._nav_cash_sum += cash / pv
            self._nav_count += 1

    # ------------------------------------------------------------------
    # Indicators
    # ------------------------------------------------------------------
    def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        # Causal Bollinger MID band = SMA(bb_period) of typical price (only
        # looks backward → no leakage). Only the mid is used as a timing
        # reference, so the std-dev multiplier is irrelevant (it scales only
        # the unused upper/lower bands) — fixed at 2.0.
        bb = qtpylib.bollinger_bands(
            qtpylib.typical_price(dataframe), window=self.bb_period.value, stds=2.0
        )
        dataframe["bb_mid"] = bb["mid"]
        return dataframe

    def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        # Initial basket fill. An unheld coin is at 0% weight — i.e. always
        # below its target by more than the band — so the drift trigger is
        # implicitly satisfied and the BB gate alone decides WHEN to start
        # the position (below mid-BB). Freqtrade opens at most one trade per
        # pair, and max_open_trades (= number of coins, set in config) caps
        # the basket. Actual size is set in custom_stake_amount().
        # LOOKAHEAD: close and bb_mid are same-row, current-candle values.
        if self.use_bb_gate.value:
            eb = self.entry_band
            eb = eb.value if hasattr(eb, "value") else eb
            thresh = dataframe["bb_mid"] * (1.0 - eb)
            dataframe.loc[dataframe["close"] < thresh, "enter_long"] = 1
        else:
            # Raw arm: fill the basket ASAP, no BB timing.
            dataframe["enter_long"] = 1
        return dataframe

    def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        # No signal-driven exits. Positions only shrink via rebalance trims.
        return dataframe

    # ------------------------------------------------------------------
    # Weight target — variant-specific (constant-mix vs CPPI)
    # ------------------------------------------------------------------
    def get_target_weight(self, pair: str, current_time: datetime, dataframe: DataFrame) -> float:
        """Target weight (fraction of total portfolio value) for `pair`.

        Subclasses implement the portfolio philosophy here. The base returns 0.0
        so Github_nateemma_strategies__BasketStrategy__20260710_214739 is directly RUNNABLE without a subclass — it just does
        nothing (zero target weight => custom_stake_amount sizes no entry and
        adjust_trade_position holds nothing), rather than raising at runtime.
        """
        return 0.0

    def _portfolio_value(self) -> float:
        """Current MARK-TO-MARKET portfolio value = free cash + Σ position values.

        NOT ``wallets.get_total_stake_amount()`` — that returns the stake
        *budget* (cost basis + free cash), which is ~constant as prices move.
        Constant-mix / CPPI weights need the LIVE total so a coin's share
        reflects current prices. LOOKAHEAD: all prices are current-candle
        closes (.iloc[-1]) / current_rate — present state only.
        """
        from freqtrade.persistence import Trade

        stake_ccy = self.config["stake_currency"]
        pv = self.wallets.get_free(stake_ccy)  # the undeployed cash bucket
        for ot in Trade.get_trades_proxy(is_open=True):
            dframe, _ = self.dp.get_analyzed_dataframe(ot.pair, self.timeframe)
            if dframe is not None and len(dframe):
                price = dframe["close"].iloc[-1]
            else:
                price = ot.open_rate
            pv += ot.amount * price
        return pv

    def _deployable_value(self, current_time: datetime) -> float:
        """Portfolio value the basket is allowed to manage.

        With the profit-skim overlay OFF this is just the full portfolio value.
        With it ON, a fraction of each new equity high is ratcheted into a
        reserved `_banked` bucket that is never redeployed, so the deployable
        total = portfolio value − banked. The banked bucket only grows (one-way
        skim) — that is the "income taken off the table". LOOKAHEAD: only reads
        the current portfolio value and a monotonic high-water mark.
        """
        pv = self._portfolio_value()
        # profit_skim_enable / skim_fraction may be hyperopt Parameters OR plain
        # values — a subclass can fix them (e.g. CppiSkimBasket) to run skim as
        # a fixed income policy rather than a tuned knob.
        skim = self.profit_skim_enable
        skim = skim.value if hasattr(skim, "value") else skim
        if not skim:
            return pv
        frac = self.skim_fraction
        frac = frac.value if hasattr(frac, "value") else float(frac)

        if not hasattr(self, "_banked"):
            self._banked = 0.0
            self._skim_hwm = pv
            self._skim_last = None
            self._skim_log = current_time
            self._skim_events = 0  # counted for the exit summary
        # Ratchet once per candle: bank a slice of any new equity high.
        if current_time != self._skim_last:
            self._skim_last = current_time
            if pv > self._skim_hwm:
                self._banked += frac * (pv - self._skim_hwm)
                self._skim_hwm = pv
                self._skim_events += 1
            # Monthly income visibility — cumulative amount taken off the table.
            if (current_time - self._skim_log) >= timedelta(days=30):
                self._skim_log = current_time
                start = float(self.config.get("dry_run_wallet") or 0.0)
                pct = (self._banked / start * 100.0) if start else 0.0
                log.info(
                    "INCOME %s  banked=%.0f (%.1f%% of start)  at-risk=%.0f",
                    current_time.date(), self._banked, pct, pv - self._banked,
                )
        return max(pv - self._banked, 0.0)

    def _log_basket_summary(self) -> None:
        """Print a NAV / cash summary at backtest completion, for ANY basket
        strategy (skim or not).

        Registered via atexit because Freqtrade has no backtest-end hook. A
        held book is evaluated on its mark-to-market NAV (`total`) and its
        cash/deployed split — not on realized trades — so this surfaces the
        composition Freqtrade's own summary can't: `deployed` (Σ positions),
        `cash` (undeployed), `banked` (skim-reserved, 0 if none) and the
        average cash weight over the run (how defensive it was). Single
        parseable line to stdout; only fires in BACKTEST runmode.
        """
        try:
            if not hasattr(self, "_nav_last_pv"):
                return  # never ran
            rm = getattr(self.dp, "runmode", None)
            if rm is not None and getattr(rm, "value", "") != "backtest":
                return
            start = self._nav_start
            total = self._nav_last_pv
            cash = self._nav_last_cash
            deployed = max(total - cash, 0.0)
            banked = float(getattr(self, "_banked", 0.0))
            banked_pct = (banked / start * 100.0) if start else 0.0
            avg_cash_pct = (
                self._nav_cash_sum / self._nav_count * 100.0 if self._nav_count else 0.0
            )
            tot_ret = ((total - start) / start * 100.0) if start else 0.0
            print(
                f"BASKET_SUMMARY strategy={self.__class__.__name__} "
                f"start={start:.2f} total={total:.2f} deployed={deployed:.2f} "
                f"cash={cash:.2f} banked={banked:.2f} banked_pct={banked_pct:.2f} "
                f"avg_cash_pct={avg_cash_pct:.1f} "
                f"skim_events={int(getattr(self, '_skim_events', 0))} "
                f"total_ret_pct={tot_ret:.2f}",
                flush=True,
            )
        except Exception as exc:  # never let a summary crash the process exit
            print(
                f"BASKET_SUMMARY strategy={self.__class__.__name__} error={exc}",
                flush=True,
            )

    def _n_coins(self) -> int:
        return max(1, len(self.dp.current_whitelist()))

    # ------------------------------------------------------------------
    # Cross-sectional helpers — for weight schemes that rank coins against
    # each other (inverse-vol, momentum, min-variance). Cached per candle so
    # every pair's get_target_weight() in one candle shares one computation.
    # LOOKAHEAD: all reads are .iloc[-1] / trailing windows on the analysed
    # frame, which is truncated to the current candle in backtest.
    # ------------------------------------------------------------------
    def _cross_section(self, column: str, current_time: datetime) -> dict[str, float]:
        """Current (causal) value of `column` for every whitelist coin."""
        cache = getattr(self, "_xs_cache", (None, None))
        if cache[0] == (column, current_time):
            return cache[1]
        out: dict[str, float] = {}
        for pair in self.dp.current_whitelist():
            df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
            if df is not None and len(df) and column in df.columns:
                v = df[column].iloc[-1]
                if v == v:  # not NaN
                    out[pair] = float(v)
        self._xs_cache = ((column, current_time), out)
        return out

    def _return_matrix(self, lookback: int, current_time: datetime):
        """Aligned trailing simple returns for whitelist coins (causal).

        DataFrame: columns = pairs, rows = the last `lookback` returns. Used by
        covariance / portfolio-volatility schemes.
        """
        import pandas as pd

        cache = getattr(self, "_rm_cache", (None, None))
        if cache[0] == (lookback, current_time):
            return cache[1]
        cols = {}
        for pair in self.dp.current_whitelist():
            df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
            if df is None or len(df) < lookback + 1:
                continue
            cols[pair] = df["close"].pct_change().iloc[-lookback:].reset_index(drop=True)
        rm = pd.DataFrame(cols).dropna(how="any")
        self._rm_cache = ((lookback, current_time), rm)
        return rm

    def _periods_per_year(self) -> float:
        """Candles per year for the strategy timeframe (for annualising vol)."""
        from freqtrade.exchange import timeframe_to_minutes

        return (365.0 * 24 * 60) / timeframe_to_minutes(self.timeframe)

    def _equal_coin_weight(self) -> float:
        """Equal split of the deployable (non-cash) fraction across coins."""
        if self.target_weight_per_coin is not None:
            return float(self.target_weight_per_coin)
        return (1.0 - self.cash_target_weight.value) / self._n_coins()

    # ------------------------------------------------------------------
    # Position sizing — initial entry to target weight of total value
    # ------------------------------------------------------------------
    def custom_stake_amount(
        self,
        pair: str,
        current_time: datetime,
        current_rate: float,
        proposed_stake: float,
        min_stake: float | None,
        max_stake: float,
        leverage: float,
        entry_tag: str | None,
        side: str,
        **kwargs,
    ) -> float:
        # Size the INITIAL entry to the coin's target weight of CURRENT total
        # portfolio value (deployed + free cash), NOT Freqtrade's default.
        total = self._deployable_value(current_time)
        df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
        target_w = self.get_target_weight(pair, current_time, df)
        stake = target_w * total
        stake = min(stake, max_stake)
        if min_stake:
            # Below exchange minimum notional: don't open a dust position.
            if stake < min_stake:
                return 0.0
        return max(stake, 0.0)

    # ------------------------------------------------------------------
    # Rebalancing — the heart of the basket
    # ------------------------------------------------------------------
    def _due_for_rebalance(self, pair: str, current_time: datetime) -> bool:
        if not hasattr(self, "_last_rebalance"):
            self._last_rebalance = {}
        last = self._last_rebalance.get(pair)
        if last is None:
            return True
        return current_time - last >= timedelta(hours=self.rebalance_interval_hours.value)

    def _bb_gate_allows(
        self, adding: bool, current_rate: float, bb_mid: float, pair: str, drift: float
    ) -> bool:
        """Execution-timing gate. Add only below mid-BB (by entry_band); trim
        only above mid-BB.

        Subclasses may widen this (e.g. constant-mix's runaway override).
        """
        if adding:
            eb = self.entry_band
            eb = eb.value if hasattr(eb, "value") else eb
            return current_rate < bb_mid * (1.0 - eb)
        return current_rate > bb_mid

    def adjust_trade_position(
        self,
        trade,
        current_time: datetime,
        current_rate: float,
        current_profit: float,
        min_stake: float | None,
        max_stake: float,
        current_entry_rate: float,
        current_exit_rate: float,
        current_entry_profit: float,
        current_exit_profit: float,
        **kwargs,
    ) -> float | None:
        pair = trade.pair

        # Cadence gate: CHECK at most once per rebalance_interval_hours per
        # coin — deliberate over responsive. We stamp the check time now,
        # before evaluating, so a look that finds nothing to do (or is blocked
        # by the BB gate) still consumes the interval and we wait to the next
        # one, rather than re-checking every candle.
        if not self._due_for_rebalance(pair, current_time):
            return None
        self._last_rebalance[pair] = current_time

        df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
        if df is None or len(df) == 0:
            return None
        # LOOKAHEAD: in backtest this frame is truncated to the current
        # candle, so .iloc[-1] is the latest closed bar — never future.
        bb_mid = df["bb_mid"].iloc[-1]
        if bb_mid != bb_mid:  # NaN guard (startup)
            return None

        total = self._deployable_value(current_time)
        if total <= 0:
            return None

        # Current weight of this coin = position value / total portfolio value.
        current_value = trade.amount * current_rate
        current_weight = current_value / total

        # Hard per-position cap: a coin over the cap is trimmed straight back to
        # it, bypassing the drift band AND the BB timing gate below — a runaway
        # winner shouldn't have to wait for price to sit above mid-BB to be
        # de-risked. Reuses `total` (no extra valuation), so it's ~free; it fires
        # at the rebalance cadence, which is frequent enough for a safety bound.
        cap = self.max_position_weight
        cap = cap.value if hasattr(cap, "value") else cap
        if cap and cap > 0 and current_weight > cap:
            delta = cap * total - current_value  # negative => trim
            if not min_stake or abs(delta) >= min_stake:
                return delta

        target_weight = self.get_target_weight(pair, current_time, df)

        drift = current_weight - target_weight  # +ve == overweight
        if abs(drift) <= self.rebalance_band.value:
            return None  # inside the band — leave it alone

        desired_value = target_weight * total
        delta_value = desired_value - current_value  # +ve add, -ve trim
        adding = delta_value > 0

        # Execution-timing gate (drift already triggered; BB decides WHEN).
        # Skip entirely for the "raw band-rebalance" comparison arm.
        if self.use_bb_gate.value and not self._bb_gate_allows(
            adding, current_rate, bb_mid, pair, drift
        ):
            return None

        # Respect exchange minimum notional so small trims/adds don't fail.
        if min_stake and abs(delta_value) < min_stake:
            return None

        if adding:
            # Never try to add more cash than is available.
            delta_value = min(delta_value, max_stake)
            if delta_value <= 0:
                return None
        else:
            # Trim: keep at least min_stake in the position so a rebalance
            # trim never fully closes a coin we intend to keep maintaining.
            # (CPPI may legitimately want zero exposure — it overrides this.)
            keep_floor = (min_stake or 0.0)
            max_trim = current_value - keep_floor
            if max_trim <= 0:
                return None
            delta_value = -min(abs(delta_value), max_trim)

        return delta_value
