"""Freqtrade strategy adapter for BTC-driven altcoin basket experimentation. This module is intentionally an adapter, not the canonical source of truth for trigger detection or trade planning. The main backend webhook engine remains authoritative for: - TradingView webhook intake - duplicate prevention - accepted/rejected event journaling - accepted-trigger-cap enforcement - trade-plan generation - audit and notification flow Use this strategy as a compatibility layer when you want Freqtrade to mirror the public backend trading concepts inside a bot-friendly strategy shell. """ from __future__ import annotations from functools import reduce import pandas as pd try: from freqtrade.strategy import IStrategy except ImportError: # pragma: no cover - keeps the module importable outside Freqtrade class IStrategy: # type: ignore[no-redef] """Fallback base class so the adapter remains readable without Freqtrade.""" minimal_roi: dict[str, float] = {} stoploss: float = -0.1 timeframe: str = "1h" can_short: bool = False class BtcDipBasketAdapter(IStrategy): """Freqtrade-compatible strategy skeleton for BTC-triggered basket entries. Key boundary: - Canonical trigger detection lives in `apps/api/app`. - This adapter mirrors the public rules for compatibility and experimentation. - Do not treat this file as the authoritative risk engine or event journal. Backend concept alignment: - `intraday_crash_thresholds` mirrors `INTRADAY_CRASH_THRESHOLDS` - `daily_close_threshold` mirrors `DAILY_CLOSE_THRESHOLD` - `price_cross_down_levels` mirrors `PRICE_CROSS_DOWN_LEVELS` - `default_total_capital` mirrors `DEFAULT_TOTAL_CAPITAL` for dry-run experimentation only - `max_total_triggers` mirrors `MAX_TOTAL_TRIGGERS` for exposure planning notes only """ INTERFACE_VERSION = 3 # Conservative defaults for a skeleton adapter. Tune these only after the # backend-owned orchestration and approval flow is stable and validated. timeframe = "1h" informative_timeframe = "1d" startup_candle_count = 10 process_only_new_candles = True can_short = False minimal_roi = {"0": 0.05} stoploss = -0.12 # Backend-aligned concept parameters. # These names intentionally mirror the API settings and docs. default_total_capital = 50_000.0 max_total_triggers = 15 advisory_trigger_capital = default_total_capital / max_total_triggers intraday_crash_thresholds = [4.7, 7.0, 9.5, 12.0, 15.0, 18.0] daily_close_threshold = 3.3 price_cross_down_levels = [67000, 65000, 63000, 61000, 59000, 57000, 55000, 54000] # Backend trigger-type labels kept in one place so entry tags and comments # stay aligned with the webhook-first backend terminology. backend_trigger_intraday_crash = "intraday_crash" backend_trigger_daily_close = "daily_close" backend_trigger_three_red_daily_closes = "three_red_daily_closes" backend_trigger_price_cross_down = "price_cross_down" def informative_pairs(self) -> list[tuple[str, str]]: """Request BTC candles as the signal context for all altcoin entries. Freqtrade evaluates strategies per traded pair. The main backend is webhook-first and signal-first instead. This adapter uses BTC as an informative context asset so altcoin entries can react to mirrored BTC weakness signals without claiming that BTC itself is the traded asset. """ return [("BTC/USDT", self.timeframe), ("BTC/USDT", self.informative_timeframe)] def populate_indicators(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame: """Populate adapter-side BTC weakness indicators. This implementation assumes BTC context is present in the dataframe using `btc_`-prefixed columns. In a real Freqtrade deployment, you would source those columns from informative pairs and merge them into the target altcoin dataframe. """ dataframe = dataframe.copy() has_informative_btc = all( column in dataframe for column in ("btc_open", "btc_close", "btc_low") ) # Fallback to local OHLC columns when BTC informative columns are not yet merged. # This keeps the skeleton usable for experimentation, but it is *not* # equivalent to a proper BTC informative-pair merge. btc_open = dataframe["btc_open"] if has_informative_btc else dataframe["open"] btc_close = dataframe["btc_close"] if has_informative_btc else dataframe["close"] btc_low = dataframe["btc_low"] if has_informative_btc else dataframe["low"] dataframe["btc_signal_context_source"] = ( "informative_btc_pair" if has_informative_btc else "local_ohlc_fallback" ) # Intraday weakness is measured from the BTC session open. This maps to # the backend `intraday_crash` trigger family. dataframe["btc_intraday_crash_change_pct"] = ((btc_close - btc_open) / btc_open) * 100.0 # Daily weakness placeholders. These are conceptually correct but rely on # merged daily BTC context for production use inside Freqtrade. dataframe["btc_daily_open"] = dataframe["btc_daily_open"] if "btc_daily_open" in dataframe else btc_open dataframe["btc_daily_close"] = dataframe["btc_daily_close"] if "btc_daily_close" in dataframe else btc_close dataframe["btc_daily_close_change_pct"] = ( (dataframe["btc_daily_close"] - dataframe["btc_daily_open"]) / dataframe["btc_daily_open"] ) * 100.0 dataframe["btc_prev_close_1"] = btc_close.shift(1) dataframe["btc_prev_close_2"] = btc_close.shift(2) dataframe["btc_prev_close_3"] = btc_close.shift(3) dataframe["btc_red_day_1"] = btc_close < dataframe["btc_prev_close_1"] dataframe["btc_red_day_2"] = dataframe["btc_prev_close_1"] < dataframe["btc_prev_close_2"] dataframe["btc_red_day_3"] = dataframe["btc_prev_close_2"] < dataframe["btc_prev_close_3"] dataframe["btc_red_day_change_1"] = ( (btc_close - dataframe["btc_prev_close_1"]) / dataframe["btc_prev_close_1"] ) * 100.0 dataframe["btc_red_day_change_2"] = ( (dataframe["btc_prev_close_1"] - dataframe["btc_prev_close_2"]) / dataframe["btc_prev_close_2"] ) * 100.0 dataframe["btc_red_day_change_3"] = ( (dataframe["btc_prev_close_2"] - dataframe["btc_prev_close_3"]) / dataframe["btc_prev_close_3"] ) * 100.0 # Price ladder support uses BTC lows and closes to model backend # `price_cross_down` trigger behavior. for level in self.price_cross_down_levels: column = self._price_cross_down_column(level) dataframe[column] = (btc_low <= level) & (btc_close <= level) for threshold in self.intraday_crash_thresholds: column = self._intraday_crash_column(threshold) dataframe[column] = dataframe["btc_intraday_crash_change_pct"] <= -threshold dataframe["btc_trigger_daily_close"] = ( dataframe["btc_daily_close_change_pct"] <= -self.daily_close_threshold ) dataframe["btc_trigger_three_red_daily_closes"] = ( dataframe["btc_red_day_1"] & dataframe["btc_red_day_2"] & dataframe["btc_red_day_3"] & (dataframe["btc_red_day_change_1"] > -self.daily_close_threshold) & (dataframe["btc_red_day_change_2"] > -self.daily_close_threshold) & (dataframe["btc_red_day_change_3"] > -self.daily_close_threshold) ) dataframe["btc_trigger_intraday_crash_any"] = False for threshold in self.intraday_crash_thresholds: dataframe["btc_trigger_intraday_crash_any"] |= dataframe[ self._intraday_crash_column(threshold) ].fillna(False) dataframe["btc_trigger_price_cross_down_any"] = False for level in self.price_cross_down_levels: dataframe["btc_trigger_price_cross_down_any"] |= dataframe[ self._price_cross_down_column(level) ].fillna(False) dataframe["btc_trigger_any"] = ( dataframe["btc_trigger_intraday_crash_any"] | dataframe["btc_trigger_price_cross_down_any"] | dataframe["btc_trigger_daily_close"].fillna(False) | dataframe["btc_trigger_three_red_daily_closes"].fillna(False) ) return dataframe def populate_entry_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame: """Mark entries when any mirrored BTC weakness condition is active. Freqtrade executes per pair, so this adapter expresses the basket idea as a generic altcoin entry flag. Position sizing and coordinated basket management should still be controlled outside this file. """ dataframe = dataframe.copy() dataframe.loc[:, "enter_long"] = 0 dataframe.loc[:, "enter_tag"] = "" conditions: list[pd.Series] = [] for threshold in self.intraday_crash_thresholds: conditions.append(dataframe[self._intraday_crash_column(threshold)].fillna(False)) for level in self.price_cross_down_levels: conditions.append(dataframe[self._price_cross_down_column(level)].fillna(False)) conditions.append(dataframe["btc_trigger_daily_close"].fillna(False)) conditions.append(dataframe["btc_trigger_three_red_daily_closes"].fillna(False)) if conditions: any_trigger = reduce(lambda left, right: left | right, conditions) dataframe.loc[any_trigger, "enter_long"] = 1 dataframe.loc[any_trigger, "enter_tag"] = "backend:basket_signal" # Assign progressively stronger trigger tags so the last matching # condition becomes the most specific visible reason in Freqtrade. dataframe.loc[ dataframe["btc_trigger_daily_close"].fillna(False), "enter_tag", ] = f"backend:{self.backend_trigger_daily_close}" dataframe.loc[ dataframe["btc_trigger_three_red_daily_closes"].fillna(False), "enter_tag", ] = f"backend:{self.backend_trigger_three_red_daily_closes}" for threshold in self.intraday_crash_thresholds: dataframe.loc[ dataframe[self._intraday_crash_column(threshold)].fillna(False), "enter_tag", ] = f"backend:{self.backend_trigger_intraday_crash}:{threshold}" for level in self.price_cross_down_levels: dataframe.loc[ dataframe[self._price_cross_down_column(level)].fillna(False), "enter_tag", ] = f"backend:{self.backend_trigger_price_cross_down}:{int(level)}" return dataframe def populate_exit_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame: """Minimal exit placeholder for the adapter skeleton. The main system is basket and trigger oriented, not indicator-exit oriented. Keep exits intentionally simple here until an approved-plan handoff and portfolio-management bridge exists outside this strategy. """ dataframe = dataframe.copy() dataframe.loc[:, "exit_long"] = 0 return dataframe @staticmethod def _intraday_crash_column(threshold: float) -> str: """Build a stable dataframe column for one backend-aligned crash threshold.""" return f"btc_trigger_intraday_crash_{str(threshold).replace('.', '_')}" @staticmethod def _price_cross_down_column(level: float) -> str: """Build a stable dataframe column for one backend-aligned price ladder level.""" return f"btc_trigger_price_cross_down_{int(level)}"