"""BtcHybrid v1 - trend-following na BTC/USDT z filtrem rezimu. TEZA (wymagana przez skill strategy-framework, zanim cokolwiek policzymy): BTC porusza sie w dlugich, wyraznych rezimach. Buy&hold zbiera caly wzrost, ale placi za to drawdownem -81%. Teza tej strategii nie brzmi "pobijemy BTC w zwrocie" - brzmi "oddamy czesc zwrotu w zamian za wyjscie z najgorszych czesci bessy". Mierzalny cel to Calmar i Sortino, nie CAGR. Zrodlo przewagi: momentum/trend persistence - aktywa w silnym trendzie maja tendencje do jego kontynuacji w horyzoncie tygodni. To jest udokumentowana, trwala anomalia na wielu klasach aktywow, a nie wzorzec dopasowany do danych. Kiedy ta strategia PRZEGRA: rynek w szerokiej konsolidacji z falszywymi wybiciami (whipsaw). Wtedy bedziemy kupowac gore i sprzedawac dol. To akceptowany koszt, nie blad do zoptymalizowania. Struktura: - rezim: dzienny EMA200 + ADX -> handlujemy TYLKO w trendzie wzrostowym - wejscie: 1h, cofniecie RSI w trwajacym trendzie (nie kupowanie wybicia) - wyjscie: ATR-owy stop kroczacy + rozpad trendu - sizing: staly ulamek kapitalu (max_open_trades=1 -> pelna pozycja) Liczba parametrow hyperoptowalnych: 5. Celowo malo - przy ~8 latach danych kazdy dodatkowy parametr to prosta droga do overfittingu (patrz: PBO w etapie 4). """ from __future__ import annotations from datetime import datetime import talib.abstract as ta from pandas import DataFrame from freqtrade.persistence import Trade from freqtrade.strategy import DecimalParameter, IntParameter, IStrategy, merge_informative_pair class BtcHybrid(IStrategy): INTERFACE_VERSION = 3 timeframe = "1h" informative_timeframe = "1d" can_short = False process_only_new_candles = True startup_candle_count = 400 # Stop-loss obsluguje custom_stoploss (ATR). To jest twardy backstop. stoploss = -0.25 use_custom_stoploss = True # ROI wylaczone - wyjscie ma byc sterowane trendem, nie stalym targetem. minimal_roi = {"0": 10} # --- parametry hyperoptowalne (5) --- buy_rsi_max = IntParameter(35, 65, default=50, space="buy", optimize=True) buy_adx_min = IntParameter(15, 35, default=20, space="buy", optimize=True) atr_stop_mult = DecimalParameter(1.5, 5.0, default=3.0, decimals=1, space="sell", optimize=True) atr_trail_mult = DecimalParameter(1.0, 4.0, default=2.0, decimals=1, space="sell", optimize=True) sell_rsi_min = IntParameter(60, 90, default=75, space="sell", optimize=True) def informative_pairs(self): pairs = self.dp.current_whitelist() return [(p, self.informative_timeframe) for p in pairs] def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: # --- rezim rynkowy z interwalu dziennego --- inf = self.dp.get_pair_dataframe(pair=metadata["pair"], timeframe=self.informative_timeframe) inf["ema200"] = ta.EMA(inf, timeperiod=200) inf["ema50"] = ta.EMA(inf, timeperiod=50) inf["adx"] = ta.ADX(inf, timeperiod=14) inf["atr"] = ta.ATR(inf, timeperiod=14) # merge_informative_pair przesuwa dane o 1 swiece - bez tego mielibysmy # look-ahead bias (dzienne zamkniecie znane w srodku dnia). dataframe = merge_informative_pair(dataframe, inf, self.timeframe, self.informative_timeframe, ffill=True) # --- sygnaly na interwale 1h --- dataframe["ema50"] = ta.EMA(dataframe, timeperiod=50) dataframe["ema200"] = ta.EMA(dataframe, timeperiod=200) dataframe["rsi"] = ta.RSI(dataframe, timeperiod=14) dataframe["adx"] = ta.ADX(dataframe, timeperiod=14) dataframe["atr"] = ta.ATR(dataframe, timeperiod=14) macd = ta.MACD(dataframe) dataframe["macd"] = macd["macd"] dataframe["macdsignal"] = macd["macdsignal"] # rezim: 1 = bull (handlujemy), 0 = bear/chop (siedzimy w cashu) d = f"_{self.informative_timeframe}" dataframe["regime_bull"] = ( (dataframe[f"close{d}"] > dataframe[f"ema200{d}"]) & (dataframe[f"ema50{d}"] > dataframe[f"ema200{d}"]) ).astype(int) return dataframe def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: d = f"_{self.informative_timeframe}" dataframe.loc[ ( (dataframe["regime_bull"] == 1) & (dataframe[f"adx{d}"] > self.buy_adx_min.value) & (dataframe["close"] > dataframe["ema200"]) & (dataframe["ema50"] > dataframe["ema200"]) # cofniecie, nie pogon za wybiciem & (dataframe["rsi"] < self.buy_rsi_max.value) & (dataframe["macd"] > dataframe["macdsignal"]) & (dataframe["volume"] > 0) ), ["enter_long", "enter_tag"], ] = (1, "trend_pullback") return dataframe def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe.loc[ ( ( # rozpad trendu na interwale dziennym (dataframe["regime_bull"] == 0) # albo wykupienie po silnym ruchu | ((dataframe["rsi"] > self.sell_rsi_min.value) & (dataframe["macd"] < dataframe["macdsignal"])) ) & (dataframe["volume"] > 0) ), ["exit_long", "exit_tag"], ] = (1, "trend_break") return dataframe def custom_stoploss(self, pair: str, trade: Trade, current_time: datetime, current_rate: float, current_profit: float, after_fill: bool, **kwargs) -> float | None: """Stop ATR-owy oparty na ATR DZIENNYM, nie godzinowym. v1 uzywala ATR z 1h (~1% ceny). 3x tego = stop 3% na BTC, ktory wylatuje przy zwyklym szumie - stad 220 transakcji po srednio 16h i -51%. ATR dzienny to ~2.5-3% ceny, wiec stop ma realna przestrzen na oddech. Freqtrade zwraca stoploss relatywnie do ceny biezacej, a stop moze sie tylko zaciskac. Liczymy wiec docelowa CENE stopu i przeliczamy ja na wartosc relatywna. """ df, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) if df is None or len(df) == 0 or current_rate <= 0: return None atr = df[f"atr_{self.informative_timeframe}"].iat[-1] if not atr: return None if current_profit > 0.05: # trailing od szczytu, ktory pozycja osiagnela peak = max(trade.max_rate or current_rate, current_rate) stop_price = peak - atr * float(self.atr_trail_mult.value) else: # stop poczatkowy liczony od ceny wejscia, nie od biezacej stop_price = trade.open_rate - atr * float(self.atr_stop_mult.value) return min(stop_price / current_rate - 1, -0.005)