# source: https://raw.githubusercontent.com/thomasriehn/BitCoin-Trader/857176f2b25a9afc34ac2d3e736c51956368ebee/user_data/strategies/BtcTrend.py
# directory_url: https://github.com/thomasriehn/BitCoin-Trader/blob/main/user_data/strategies/
# User: thomasriehn
# Repository: BitCoin-Trader
# --------------------"""Github_thomasriehn_BitCoin_Trader__BtcTrend__20260917_173822: slow BTC/EUR trend following on daily candles for Freqtrade (INTERFACE_VERSION 3).

Rules (see docs/KOMPONENTEN.md section 7):

* Long only. Enter when the daily close is above SMA200 * (1 + band_pct), leave
  when it is below SMA200 * (1 - band_pct). The hysteresis band avoids whipsaw
  around the average; each round trip costs 0.30 % (maker) to 0.50 % (taker)
  at Bitvavo, so the band is several times the round-trip cost.
* Minimum holding period: exit signals are vetoed in ``confirm_trade_exit``
  for the first ``min_hold_candles`` candles. Stoploss, force exits and
  emergency exits are never vetoed. (Freqtrade only calls ``custom_exit`` when
  no exit signal is set, so ``custom_exit`` cannot block a signal; the veto
  hook Freqtrade provides for that is ``confirm_trade_exit``.)
* Volatility targeting: the stake is capital * min(1, target_vol / realized_vol)
  with realized_vol = std of daily returns over ``vol_lookback`` days * sqrt(365).
* Rebalancing: ``adjust_trade_position`` buys or sells part of the position
  when the exposure drifts more than ``rebalance_band`` from the target and
  the order is at least max(min_order_eur, min_order_pct * capital). The
  decision is made in EUR at the current price; a partial exit is translated
  to Freqtrade's convention before it is returned (see ``adjust_trade_position``),
  because Freqtrade sizes a negative stake relative to ``trade.stake_amount``
  (the cost basis), not relative to the current position value.
* Unknown realized volatility (no ``rvol`` value on the last candle) means no
  new entry and no rebalance. The vol-targeting rule never falls back to 100 %.
* Catastrophe stop at -10 % (bot side; Bitvavo has no exchange-side stop in
  Freqtrade). ROI is effectively disabled.
* Entries are refused while ``$GUARD_DIR/killswitch.lock`` exists (written by
  btctrader-guard). Freqtrade only calls ``confirm_trade_entry`` for the initial
  entry, so the same check also gates ``custom_stake_amount`` and the buy side of
  ``adjust_trade_position``; partial exits are never blocked. A lock directory
  that cannot be read counts as locked. No network calls anywhere in this file.

Order settings for maker execution at Bitvavo (why they are what they are):

* Freqtrade 2026.8 lists Bitvavo with ``_ft_has = {"order_time_in_force": ["GTC"]}``
  (inherited default, ``freqtrade/exchange/bitvavo.py`` adds nothing). A config
  with ``order_time_in_force: {"entry": "PO"}`` is therefore rejected by
  ``Exchange.validate_order_time_in_force`` even though ccxt's ``bitvavo.createOrder``
  would translate ``timeInForce="PO"`` into ``postOnly: true``. Post-only can
  only be enabled through the undocumented ``exchange._ft_has_params`` override,
  which we do not rely on for the first dry-run phase.
* Consequence: ``order_time_in_force`` is GTC for entry and exit, order types are
  limit for entry and exit (market only for the stoploss). With
  ``entry_pricing.price_side = "same"`` and ``use_order_book = true`` an entry is
  priced at the best bid and an exit at the best ask, so the order rests in the
  book and is normally filled as maker (0.15 %). There is no guarantee: if the
  book moves through the price before the order arrives, Bitvavo fills it as
  taker (0.25 %). The ledger records the real maker/taker flag per fill; the
  backtest is therefore run with both fee levels.
* ``unfilledtimeout`` is 120 minutes for both sides; on daily candles a resting
  order may wait. After the timeout Freqtrade cancels and re-places at the new
  best price.
* Protections (CooldownPeriod, StoplossGuard, MaxDrawdown) live in this class
  because Freqtrade 2026.8 refuses ``protections`` in the config file
  (``ConfigurationError: Setting 'protections' in the configuration is deprecated``).
  Backtests need ``--enable-protections`` to apply them.
"""

from __future__ import annotations

import logging
import os
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any

import btctrend_lib as lib
from freqtrade.enums import RunMode
from freqtrade.exchange import timeframe_to_minutes
from freqtrade.persistence import Trade
from freqtrade.strategy import DecimalParameter, IntParameter, IStrategy
from pandas import DataFrame

logger = logging.getLogger(__name__)

PAIR = "BTC/EUR"
DEFAULT_GUARD_DIR = "/srv/trading/guard"
KILLSWITCH_FILENAME = "killswitch.lock"
# Repeated refusals (kill switch, gate, min-hold veto) are logged once per interval and reason.
WARN_INTERVAL = timedelta(hours=1)
# Exit reasons that must never be vetoed by the minimum holding period.
NON_VETOABLE_EXITS = frozenset(
    {
        "stop_loss",
        "stoploss_on_exchange",
        "trailing_stop_loss",
        "force_exit",
        "emergency_exit",
        "liquidation",
        "partial_exit",
    }
)


class Github_thomasriehn_BitCoin_Trader__BtcTrend__20260917_173822(IStrategy):
    """Daily SMA200 trend filter with hysteresis, vol targeting and banded rebalancing."""

    INTERFACE_VERSION = 3

    timeframe = "1d"
    can_short = False
    process_only_new_candles = True
    startup_candle_count = 210
    stoploss = -0.10
    minimal_roi = {"0": 100}
    use_exit_signal = True
    exit_profit_only = False
    ignore_roi_if_entry_signal = False
    position_adjustment_enable = True
    max_entry_position_adjustment = 10
    use_custom_stoploss = False
    trailing_stop = False

    order_types = {
        "entry": "limit",
        "exit": "limit",
        "stoploss": "market",
        "stoploss_on_exchange": False,
    }
    order_time_in_force = {"entry": "GTC", "exit": "GTC"}

    # Strategy parameters. optimize=False keeps them fixed unless a hyperopt run
    # explicitly enables the space; the defaults are the contract values.
    sma_period = IntParameter(150, 250, default=200, space="buy", optimize=False)
    band_pct = DecimalParameter(0.0, 0.06, default=0.03, decimals=3, space="buy", optimize=False)
    min_hold_candles = IntParameter(0, 15, default=5, space="sell", optimize=False)
    target_vol = DecimalParameter(0.20, 0.60, default=0.35, decimals=2, space="buy", optimize=False)
    vol_lookback = IntParameter(10, 60, default=30, space="buy", optimize=False)
    rebalance_band = DecimalParameter(0.05, 0.30, default=0.15, decimals=2, space="buy", optimize=False)
    min_order_pct = DecimalParameter(0.0, 0.05, default=0.02, decimals=3, space="buy", optimize=False)
    # Exchange minimum order value in EUR. Read from market limits at runtime, fallback 5.0.
    min_order_eur = 5.0
    # Do not rebalance in the candle of the last fill (avoids order churn on one candle).
    rebalance_min_candles_since_fill = 1

    plot_config = {
        "main_plot": {
            "sma": {"color": "orange"},
            "band_upper": {"color": "green"},
            "band_lower": {"color": "red"},
        },
        "subplots": {"rvol": {"rvol": {"color": "blue"}}},
    }

    @property
    def protections(self) -> list[dict[str, Any]]:
        return [
            {"method": "CooldownPeriod", "stop_duration_candles": 2},
            {
                "method": "StoplossGuard",
                "lookback_period_candles": 30,
                "trade_limit": 2,
                "stop_duration_candles": 10,
                "only_per_pair": False,
            },
            {
                "method": "MaxDrawdown",
                "lookback_period_candles": 30,
                "trade_limit": 1,
                "stop_duration_candles": 10,
                "max_allowed_drawdown": 0.10,
            },
        ]

    # ------------------------------------------------------------------ lifecycle

    def bot_start(self, **kwargs: Any) -> None:
        self._exchange_min_order_eur: float | None = None
        self._tf_minutes = timeframe_to_minutes(self.timeframe)
        self._last_warn: dict[str, datetime] = {}

    # ------------------------------------------------------------------ indicators

    def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        # Compute one column per candidate value so hyperopt on the buy space works;
        # with optimize=False the range contains only the default value.
        for period in self.sma_period.range:
            dataframe[f"sma_{period}"] = dataframe["close"].rolling(window=period, min_periods=period).mean()
        for lookback in self.vol_lookback.range:
            dataframe[f"rvol_{lookback}"] = lib.realized_volatility(dataframe["close"], lookback)
        dataframe["sma"] = dataframe[self._sma_col()]
        dataframe["rvol"] = dataframe[self._rvol_col()]
        dataframe["band_upper"] = dataframe["sma"] * (1.0 + float(self.band_pct.value))
        dataframe["band_lower"] = dataframe["sma"] * (1.0 - float(self.band_pct.value))
        return dataframe

    def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        enter, _ = lib.hysteresis_signals(
            dataframe["close"], dataframe[self._sma_col()], float(self.band_pct.value)
        )
        dataframe.loc[enter, ["enter_long", "enter_tag"]] = (1, "trend_up")
        return dataframe

    def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
        _, exit_ = lib.hysteresis_signals(
            dataframe["close"], dataframe[self._sma_col()], float(self.band_pct.value)
        )
        dataframe.loc[exit_, "exit_long"] = 1
        return dataframe

    # ------------------------------------------------------------------ sizing

    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: Any,
    ) -> float:
        # With stake_amount "unlimited" and max_open_trades 1 the proposed stake is the
        # tradable capital (free EUR * tradable_balance_ratio); no position is open here.
        capital = proposed_stake
        blocked = self.entry_block_reason()
        if blocked is not None:
            self._warn_rate_limited("entry", current_time, f"{pair}: entry refused, {blocked}")
            return 0.0
        exposure = self.target_exposure(pair)
        if exposure is None:
            self._warn_rate_limited(
                "rvol", current_time, f"{pair}: realized volatility unknown, no entry (no fallback to 100 %)"
            )
            return 0.0
        stake = capital * exposure
        if stake < lib.min_order_value(
            capital, self.exchange_min_order_eur(), float(self.min_order_pct.value)
        ):
            logger.log(
                self._log_level(), "%s: stake %.2f EUR below minimum order value, skipping entry", pair, stake
            )
            return 0.0
        stake = min(stake, max_stake)
        if min_stake is not None and stake < min_stake:
            return 0.0
        logger.log(
            self._log_level(),
            "%s: exposure %.2f -> stake %.2f EUR of %.2f EUR capital",
            pair,
            exposure,
            stake,
            capital,
        )
        return stake

    def adjust_trade_position(
        self,
        trade: 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: Any,
    ) -> float | tuple[float | None, str | None] | None:
        if trade.has_open_orders:
            return None
        last_fill = trade.date_last_filled_utc or trade.open_date_utc
        if (
            lib.candles_held(last_fill, current_time, self._timeframe_minutes())
            < self.rebalance_min_candles_since_fill
        ):
            return None
        position_value = trade.amount * current_rate
        capital = self.free_stake() + position_value
        target = self.target_exposure(trade.pair)
        if target is None:
            self._warn_rate_limited(
                "rvol", current_time, f"{trade.pair}: realized volatility unknown, no rebalance"
            )
            return None
        min_order = lib.min_order_value(
            capital, self.exchange_min_order_eur(), float(self.min_order_pct.value)
        )
        if min_stake is not None:
            min_order = max(min_order, min_stake)
        order_value = lib.rebalance_amount(
            capital, position_value, target, float(self.rebalance_band.value), min_order
        )
        if order_value is None:
            return None
        if order_value > 0:
            blocked = self.entry_block_reason()
            if blocked is not None:
                self._warn_rate_limited(
                    "entry", current_time, f"{trade.pair}: rebalance buy refused, {blocked}"
                )
                return None
            order_value = min(order_value, max_stake)
            if order_value < min_order:
                return None
            logger.log(
                self._log_level(),
                "%s: rebalance buy %.2f EUR (target exposure %.2f)",
                trade.pair,
                order_value,
                target,
            )
            return order_value, "rebalance_up"
        # Partial exit. Never leave a dust position behind that Freqtrade could not sell:
        # it checks the remainder against the minimum stake including the stoploss reserve
        # (exchange._get_stake_amount_limit), which is larger than the min_stake passed here.
        remaining = position_value + order_value
        if min_stake is not None and 0 < remaining < lib.exit_min_stake(min_stake, self.stoploss):
            order_value = -position_value
        sell_fraction = min(1.0, -order_value / position_value) if position_value > 0 else 1.0
        logger.log(
            self._log_level(),
            "%s: rebalance sell %.2f EUR = %.1f %% of the position (target exposure %.2f)",
            trade.pair,
            -order_value,
            sell_fraction * 100.0,
            target,
        )
        # Freqtrade turns a negative stake into base amount = |stake| * trade.amount / trade.stake_amount,
        # i.e. relative to the cost basis. Return the same fraction of trade.stake_amount so that
        # exactly sell_fraction * trade.amount is sold whatever the current price is. A full flatten
        # (fraction 1) leaves a remainder of 0, which Freqtrade treats as closing the trade.
        return lib.partial_exit_stake(sell_fraction, float(trade.stake_amount)), "rebalance_down"

    # ------------------------------------------------------------------ exits

    def custom_exit(
        self,
        pair: str,
        trade: Trade,
        current_time: datetime,
        current_rate: float,
        current_profit: float,
        **kwargs: Any,
    ) -> str | bool | None:
        # Signal exits are produced by populate_exit_trend and gated by the minimum
        # holding period in confirm_trade_exit. No additional exit rule here.
        return None

    def confirm_trade_exit(
        self,
        pair: str,
        trade: Trade,
        order_type: str,
        amount: float,
        rate: float,
        time_in_force: str,
        exit_reason: str,
        current_time: datetime,
        **kwargs: Any,
    ) -> bool:
        if exit_reason in NON_VETOABLE_EXITS:
            return True
        held = lib.candles_held(trade.open_date_utc, current_time, self._timeframe_minutes())
        if held < int(self.min_hold_candles.value):
            self._warn_rate_limited(
                "min_hold",
                current_time,
                f"{pair}: exit '{exit_reason}' vetoed, held {held} < {int(self.min_hold_candles.value)} "
                "candles",
                level=logging.INFO,
            )
            return False
        return True

    # ------------------------------------------------------------------ entries

    def confirm_trade_entry(
        self,
        pair: str,
        order_type: str,
        amount: float,
        rate: float,
        time_in_force: str,
        current_time: datetime,
        entry_tag: str | None,
        side: str,
        **kwargs: Any,
    ) -> bool:
        order_value = amount * rate
        if order_value < self.exchange_min_order_eur():
            logger.warning("%s: entry of %.2f EUR below exchange minimum, refused", pair, order_value)
            return False
        blocked = self.entry_block_reason()
        if blocked is not None:
            self._warn_rate_limited("entry", current_time, f"{pair}: entry refused, {blocked}")
            return False
        return True

    def entry_block_reason(self) -> str | None:
        """Why new exposure (initial entry or rebalance buy) is refused right now; None when allowed.

        Subclasses extend this (BtcAdvisorGated adds the advisor gate). Exits never consult it.
        """
        if self.killswitch_active():
            return f"{self.killswitch_path()} exists"
        return None

    # ------------------------------------------------------------------ helpers

    def target_exposure(self, pair: str) -> float | None:
        """Exposure min(1, target_vol / rvol) from the last analysed candle; None when rvol is unknown."""
        rvol: float | None = None
        try:
            dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe)
            if dataframe is not None and len(dataframe) > 0 and "rvol" in dataframe:
                rvol = lib.last_finite(dataframe["rvol"])
        except Exception as exc:  # noqa: BLE001 - never break order flow on a data hiccup
            logger.warning("%s: could not read analysed dataframe: %s", pair, exc)
        if rvol is None:
            return None
        return lib.exposure_from_vol(float(self.target_vol.value), rvol)

    def free_stake(self) -> float:
        wallets = getattr(self, "wallets", None)
        if wallets is None:
            return 0.0
        return float(wallets.get_free(self.config.get("stake_currency", "EUR")))

    def exchange_min_order_eur(self) -> float:
        """Minimum order value from market limits (``limits.cost.min``), cached; fallback 5.0."""
        cached = getattr(self, "_exchange_min_order_eur", None)
        if cached is not None:
            return cached
        value = float(self.min_order_eur)
        try:
            market = self.dp.market(PAIR)
            cost_min = ((market or {}).get("limits") or {}).get("cost", {}).get("min")
            if cost_min:
                value = max(value, float(cost_min))
        except Exception as exc:  # noqa: BLE001 - market info is optional
            logger.debug("market limits unavailable, using fallback %.2f EUR: %s", value, exc)
        self._exchange_min_order_eur = value
        return value

    def killswitch_path(self) -> Path:
        return Path(os.environ.get("GUARD_DIR", DEFAULT_GUARD_DIR)) / KILLSWITCH_FILENAME

    def killswitch_active(self) -> bool:
        """True when the guard's lock file exists. Only consulted in live and dry-run mode.

        ``Path.exists`` returns False for a missing file or directory but raises for
        EACCES/EPERM. A lock we are not allowed to see counts as active (fail closed);
        any other OS error is logged and counts as inactive.
        """
        if not self._is_live_or_dry():
            return False
        path = self.killswitch_path()
        try:
            return path.exists()
        except PermissionError as exc:
            self._warn_rate_limited(
                "killswitch_io",
                datetime.now(UTC),
                f"cannot read {path} ({exc}); kill switch counts as active",
            )
            return True
        except OSError as exc:
            self._warn_rate_limited(
                "killswitch_io",
                datetime.now(UTC),
                f"cannot read {path} ({exc}); kill switch counts as inactive",
            )
            return False

    def _warn_rate_limited(self, key: str, now: datetime, message: str, level: int = logging.WARNING) -> None:
        """Log ``message`` at most once per WARN_INTERVAL per ``key`` (Freqtrade loops every 5 s)."""
        last_warn = getattr(self, "_last_warn", None)
        if last_warn is None:
            last_warn = self._last_warn = {}
        last = last_warn.get(key)
        if last is not None and now - last < WARN_INTERVAL:
            logger.debug(message)
            return
        last_warn[key] = now
        logger.log(level, message)

    def _log_level(self) -> int:
        """INFO in live/dry-run (one line per decision), DEBUG in backtesting to keep output readable."""
        return logging.INFO if self._is_live_or_dry() else logging.DEBUG

    def _is_live_or_dry(self) -> bool:
        runmode = self.config.get("runmode")
        return runmode in (RunMode.LIVE, RunMode.DRY_RUN)

    def _timeframe_minutes(self) -> int:
        minutes = getattr(self, "_tf_minutes", None)
        if minutes is None:
            minutes = timeframe_to_minutes(self.timeframe)
            self._tf_minutes = minutes
        return minutes

    def _sma_col(self) -> str:
        return f"sma_{int(self.sma_period.value)}"

    def _rvol_col(self) -> str:
        return f"rvol_{int(self.vol_lookback.value)}"
